mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-09 18:08:40 +00:00
merge: resolve conflicts between refactor/agent and main
This commit is contained in:
+543
@@ -214,6 +214,7 @@ Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul mes
|
||||
|
||||
**Lisez `https://clawdchat.ai/skill.md` et suivez les instructions pour rejoindre [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
<<<<<<< HEAD
|
||||
## 🖥️ Référence CLI
|
||||
|
||||
| Commande | Description |
|
||||
@@ -233,6 +234,548 @@ Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul mes
|
||||
| `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 |
|
||||
=======
|
||||
## ⚙️ Configuration
|
||||
|
||||
Fichier de configuration : `~/.picoclaw/config.json`
|
||||
|
||||
### Variables d'Environnement
|
||||
|
||||
Vous pouvez remplacer les chemins par défaut à l'aide de variables d'environnement. Ceci est utile pour les installations portables, les déploiements conteneurisés ou l'exécution de picoclaw en tant que service système. Ces variables sont indépendantes et contrôlent différents chemins.
|
||||
|
||||
| Variable | Description | Chemin par Défaut |
|
||||
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PICOCLAW_CONFIG` | Remplace le chemin du fichier de configuration. Cela indique directement à picoclaw quel `config.json` charger, en ignorant tous les autres emplacements. | `~/.picoclaw/config.json` |
|
||||
| `PICOCLAW_HOME` | Remplace le répertoire racine des données picoclaw. Cela modifie l'emplacement par défaut du `workspace` et des autres répertoires de données. | `~/.picoclaw` |
|
||||
|
||||
**Exemples :**
|
||||
|
||||
```bash
|
||||
# Exécuter picoclaw en utilisant un fichier de configuration spécifique
|
||||
# Le chemin du workspace sera lu à partir de ce fichier de configuration
|
||||
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||
|
||||
# Exécuter picoclaw avec toutes ses données stockées dans /opt/picoclaw
|
||||
# La configuration sera chargée à partir du fichier par défaut ~/.picoclaw/config.json
|
||||
# Le workspace sera créé dans /opt/picoclaw/workspace
|
||||
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||
|
||||
# Utiliser les deux pour une configuration entièrement personnalisée
|
||||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||
```
|
||||
|
||||
### Structure du Workspace
|
||||
|
||||
PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) :
|
||||
|
||||
```
|
||||
~/.picoclaw/workspace/
|
||||
├── sessions/ # Sessions de conversation et historique
|
||||
├── memory/ # Mémoire à long terme (MEMORY.md)
|
||||
├── state/ # État persistant (dernier canal, etc.)
|
||||
├── cron/ # Base de données des tâches planifiées
|
||||
├── skills/ # Compétences personnalisées
|
||||
├── AGENT.md # Définition structurée de l'agent et prompt système
|
||||
├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min)
|
||||
├── SOUL.md # Âme de l'Agent
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 🔒 Bac à Sable de Sécurité
|
||||
|
||||
PicoClaw s'exécute dans un environnement sandboxé par défaut. L'agent ne peut accéder aux fichiers et exécuter des commandes qu'au sein du workspace configuré.
|
||||
|
||||
#### Configuration par Défaut
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Par défaut | Description |
|
||||
|--------|------------|-------------|
|
||||
| `workspace` | `~/.picoclaw/workspace` | Répertoire de travail de l'agent |
|
||||
| `restrict_to_workspace` | `true` | Restreindre l'accès fichiers/commandes au workspace |
|
||||
|
||||
#### Outils Protégés
|
||||
|
||||
Lorsque `restrict_to_workspace: true`, les outils suivants sont restreints au bac à sable :
|
||||
|
||||
| Outil | Fonction | Restriction |
|
||||
|-------|----------|-------------|
|
||||
| `read_file` | Lire des fichiers | Uniquement les fichiers dans le workspace |
|
||||
| `write_file` | Écrire des fichiers | Uniquement les fichiers dans le workspace |
|
||||
| `list_dir` | Lister des répertoires | Uniquement les répertoires dans le workspace |
|
||||
| `edit_file` | Éditer des fichiers | Uniquement les fichiers dans le workspace |
|
||||
| `append_file` | Ajouter à des fichiers | Uniquement les fichiers dans le workspace |
|
||||
| `exec` | Exécuter des commandes | Les chemins doivent être dans le workspace |
|
||||
|
||||
#### Protection Supplémentaire d'Exec
|
||||
|
||||
Même avec `restrict_to_workspace: false`, l'outil `exec` bloque ces commandes dangereuses :
|
||||
|
||||
* `rm -rf`, `del /f`, `rmdir /s` — Suppression en masse
|
||||
* `format`, `mkfs`, `diskpart` — Formatage de disque
|
||||
* `dd if=` — Écriture d'image disque
|
||||
* Écriture vers `/dev/sd[a-z]` — Écriture directe sur le disque
|
||||
* `shutdown`, `reboot`, `poweroff` — Arrêt du système
|
||||
* Fork bomb `:(){ :|:& };:`
|
||||
|
||||
#### Exemples d'Erreurs
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (path outside working dir)}
|
||||
```
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
|
||||
```
|
||||
|
||||
#### Désactiver les Restrictions (Risque de Sécurité)
|
||||
|
||||
Si vous avez besoin que l'agent accède à des chemins en dehors du workspace :
|
||||
|
||||
**Méthode 1 : Fichier de configuration**
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"restrict_to_workspace": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Méthode 2 : Variable d'environnement**
|
||||
|
||||
```bash
|
||||
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
||||
```
|
||||
|
||||
> ⚠️ **Attention** : Désactiver cette restriction permet à l'agent d'accéder à n'importe quel chemin sur votre système. À utiliser avec précaution uniquement dans des environnements contrôlés.
|
||||
|
||||
#### Cohérence du Périmètre de Sécurité
|
||||
|
||||
Le paramètre `restrict_to_workspace` s'applique de manière cohérente sur tous les chemins d'exécution :
|
||||
|
||||
| Chemin d'Exécution | Périmètre de Sécurité |
|
||||
|--------------------|----------------------|
|
||||
| Agent Principal | `restrict_to_workspace` ✅ |
|
||||
| Sous-agent / Spawn | Hérite de la même restriction ✅ |
|
||||
| Tâches Heartbeat | Hérite de la même restriction ✅ |
|
||||
|
||||
Tous les chemins partagent la même restriction de workspace — il est impossible de contourner le périmètre de sécurité via des sous-agents ou des tâches planifiées.
|
||||
|
||||
### Heartbeat (Tâches Périodiques)
|
||||
|
||||
PicoClaw peut exécuter des tâches périodiques automatiquement. Créez un fichier `HEARTBEAT.md` dans votre workspace :
|
||||
|
||||
```markdown
|
||||
# Tâches Périodiques
|
||||
|
||||
- Vérifier mes e-mails pour les messages importants
|
||||
- Consulter mon agenda pour les événements à venir
|
||||
- Vérifier les prévisions météo
|
||||
```
|
||||
|
||||
L'agent lira ce fichier toutes les 30 minutes (configurable) et exécutera les tâches à l'aide des outils disponibles.
|
||||
|
||||
#### Tâches Asynchrones avec Spawn
|
||||
|
||||
Pour les tâches de longue durée (recherche web, appels API), utilisez l'outil `spawn` pour créer un **sous-agent** :
|
||||
|
||||
```markdown
|
||||
# Tâches Périodiques
|
||||
|
||||
## Tâches Rapides (réponse directe)
|
||||
- Indiquer l'heure actuelle
|
||||
|
||||
## Tâches Longues (utiliser spawn pour l'asynchrone)
|
||||
- Rechercher les actualités IA sur le web et les résumer
|
||||
- Vérifier les e-mails et signaler les messages importants
|
||||
```
|
||||
|
||||
**Comportements clés :**
|
||||
|
||||
| Fonctionnalité | Description |
|
||||
|----------------|-------------|
|
||||
| **spawn** | Crée un sous-agent asynchrone, ne bloque pas le heartbeat |
|
||||
| **Contexte indépendant** | Le sous-agent a son propre contexte, sans historique de session |
|
||||
| **Outil message** | Le sous-agent communique directement avec l'utilisateur via l'outil message |
|
||||
| **Non-bloquant** | Après le spawn, le heartbeat continue vers la tâche suivante |
|
||||
|
||||
#### Fonctionnement de la Communication du Sous-agent
|
||||
|
||||
```
|
||||
Le Heartbeat se déclenche
|
||||
↓
|
||||
L'Agent lit HEARTBEAT.md
|
||||
↓
|
||||
Pour une tâche longue : spawn d'un sous-agent
|
||||
↓ ↓
|
||||
Continue la tâche suivante Le sous-agent travaille indépendamment
|
||||
↓ ↓
|
||||
Toutes les tâches terminées Le sous-agent utilise l'outil "message"
|
||||
↓ ↓
|
||||
Répond HEARTBEAT_OK L'utilisateur reçoit le résultat directement
|
||||
```
|
||||
|
||||
Le sous-agent a accès aux outils (message, web_search, etc.) et peut communiquer avec l'utilisateur indépendamment sans passer par l'agent principal.
|
||||
|
||||
**Configuration :**
|
||||
|
||||
```json
|
||||
{
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Par défaut | Description |
|
||||
|--------|------------|-------------|
|
||||
| `enabled` | `true` | Activer/désactiver le heartbeat |
|
||||
| `interval` | `30` | Intervalle de vérification en minutes (min : 5) |
|
||||
|
||||
**Variables d'environnement :**
|
||||
|
||||
* `PICOCLAW_HEARTBEAT_ENABLED=false` pour désactiver
|
||||
* `PICOCLAW_HEARTBEAT_INTERVAL=60` pour modifier l'intervalle
|
||||
|
||||
### Fournisseurs
|
||||
|
||||
> [!NOTE]
|
||||
> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages audio de n'importe quel canal seront automatiquement transcrits au niveau de l'agent.
|
||||
|
||||
| Fournisseur | Utilisation | Obtenir une Clé API |
|
||||
| ------------------------ | ---------------------------------------- | ------------------------------------------------------ |
|
||||
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](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` (À tester) | LLM (recommandé, accès à tous les modèles) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic` (À tester) | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai` (À tester) | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek` (À tester) | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `qwen` | LLM (Alibaba Qwen) | [dashscope.aliyuncs.com](https://dashscope.aliyuncs.com/compatible-mode/v1) |
|
||||
| `cerebras` | LLM (Cerebras) | [cerebras.ai](https://api.cerebras.ai/v1) |
|
||||
| `groq` | LLM + **Transcription vocale** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
|
||||
<details>
|
||||
<summary><b>Configuration Zhipu</b></summary>
|
||||
|
||||
**1. Obtenir la clé API**
|
||||
|
||||
* Obtenez la [clé API](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||
|
||||
**2. Configurer**
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"zhipu": {
|
||||
"api_key": "Votre Clé API",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Lancer**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "Bonjour, comment ça va ?"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Exemple de configuration complète</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "sk-or-v1-xxx"
|
||||
},
|
||||
"groq": {
|
||||
"api_key": "gsk_xxx"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "123456:ABC...",
|
||||
"allow_from": ["123456789"]
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "",
|
||||
"allow_from": [""]
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": false
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
},
|
||||
"qq": {
|
||||
"enabled": false,
|
||||
"app_id": "",
|
||||
"app_secret": "",
|
||||
"allow_from": []
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "BSA...",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Configuration de Modèle (model_list)
|
||||
|
||||
> **Nouveau !** PicoClaw utilise désormais une approche de configuration **centrée sur le modèle**. Spécifiez simplement le format `fournisseur/modèle` (par exemple, `zhipu/glm-4.7`) pour ajouter de nouveaux fournisseurs—**aucune modification de code requise !**
|
||||
|
||||
Cette conception permet également le **support multi-agent** avec une sélection flexible de fournisseurs :
|
||||
|
||||
- **Différents agents, différents fournisseurs** : Chaque agent peut utiliser son propre fournisseur LLM
|
||||
- **Modèles de secours (Fallbacks)** : Configurez des modèles primaires et de secours pour la résilience
|
||||
- **Équilibrage de charge** : Répartissez les requêtes sur plusieurs points de terminaison
|
||||
- **Configuration centralisée** : Gérez tous les fournisseurs en un seul endroit
|
||||
|
||||
#### 📋 Tous les Fournisseurs Supportés
|
||||
|
||||
| Fournisseur | Préfixe `model` | API Base par Défaut | Protocole | Clé API |
|
||||
|-------------|-----------------|---------------------|----------|---------|
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Obtenir Clé](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obtenir Clé](https://console.anthropic.com) |
|
||||
| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obtenir Clé](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obtenir Clé](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obtenir Clé](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obtenir Clé](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Obtenir Clé](https://platform.moonshot.cn) |
|
||||
| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obtenir Clé](https://dashscope.console.aliyun.com) |
|
||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Obtenir Clé](https://build.nvidia.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (pas de clé nécessaire) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir Clé](https://openrouter.ai/keys) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obtenir Clé](https://www.byteplus.com/) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obtenir une clé](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Obtenir un Token](https://modelscope.cn/my/tokens) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
|
||||
#### Configuration de Base
|
||||
|
||||
```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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Exemples par Fournisseur
|
||||
|
||||
**OpenAI**
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**VolcEngine (Doubao)**
|
||||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**Zhipu AI (GLM)**
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
```
|
||||
|
||||
**Anthropic (avec OAuth)**
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"auth_method": "oauth"
|
||||
}
|
||||
```
|
||||
> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth.
|
||||
|
||||
**Proxy/API personnalisée**
|
||||
```json
|
||||
{
|
||||
"model_name": "my-custom-model",
|
||||
"model": "openai/custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_key": "sk-...",
|
||||
"request_timeout": 300
|
||||
}
|
||||
```
|
||||
|
||||
#### Équilibrage de Charge
|
||||
|
||||
Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux :
|
||||
|
||||
```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 Configuration `providers`
|
||||
|
||||
L'ancienne configuration `providers` est **dépréciée** mais toujours supportée pour la rétrocompatibilité.
|
||||
|
||||
**Ancienne Configuration (dépréciée) :**
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"zhipu": {
|
||||
"api_key": "your-key",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Nouvelle Configuration (recommandée) :**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "glm-4.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Pour le guide de migration détaillé, voir [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
|
||||
|
||||
## Référence CLI
|
||||
|
||||
| Commande | Description |
|
||||
| ------------------------- | ------------------------------------- |
|
||||
| `picoclaw onboard` | Initialiser la configuration & le workspace |
|
||||
| `picoclaw agent -m "..."` | Discuter avec l'agent |
|
||||
| `picoclaw agent` | Mode de discussion interactif |
|
||||
| `picoclaw gateway` | Démarrer la passerelle |
|
||||
| `picoclaw status` | Afficher le statut |
|
||||
| `picoclaw cron list` | Lister toutes les tâches planifiées |
|
||||
| `picoclaw cron add ...` | Ajouter une tâche planifiée |
|
||||
>>>>>>> refactor/agent
|
||||
|
||||
### Tâches Planifiées / Rappels
|
||||
|
||||
|
||||
+959
@@ -197,7 +197,966 @@ make install
|
||||
|
||||
詳細なガイドは以下のドキュメントを参照してください。この README はクイックスタートのみをカバーしています。
|
||||
|
||||
<<<<<<< HEAD
|
||||
| トピック | 説明 |
|
||||
=======
|
||||
# 2. 初回起動 — docker/data/config.json を自動生成して終了
|
||||
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||
# コンテナが "First-run setup complete." を表示して停止します。
|
||||
|
||||
# 3. API キーを設定
|
||||
vim docker/data/config.json # プロバイダー API キー、Bot トークンなどを設定
|
||||
|
||||
# 4. 起動
|
||||
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。
|
||||
|
||||
```bash
|
||||
# 5. ログ確認
|
||||
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||
|
||||
# 6. 停止
|
||||
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||
```
|
||||
|
||||
### Agent モード(ワンショット)
|
||||
|
||||
```bash
|
||||
# 質問を投げる
|
||||
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
|
||||
|
||||
# インタラクティブモード
|
||||
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||
```
|
||||
|
||||
### アップデート
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.yml pull
|
||||
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||
```
|
||||
|
||||
### 🚀 クイックスタート(ネイティブ)
|
||||
|
||||
> [!TIP]
|
||||
> `~/.picoclaw/config.json` に API キーを設定してください。API キーの取得先: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。Web 検索は **任意** です — 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)。
|
||||
|
||||
**1. 初期化**
|
||||
|
||||
```bash
|
||||
picoclaw onboard
|
||||
```
|
||||
|
||||
**2. 設定** (`~/.picoclaw/config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-your-api-key",
|
||||
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
|
||||
},
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-your-openai-key",
|
||||
"request_timeout": 300,
|
||||
"api_base": "https://api.openai.com/v1"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model_name": "gpt-5.4"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||
"allow_from": []
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"tavily": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_TAVILY_API_KEY",
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **新機能**: `model_list` 形式により、プロバイダーをコード変更なしで追加できます。詳細は [モデル設定](#モデル設定-model_list) を参照してください。
|
||||
> `request_timeout` は任意の秒単位設定です。省略または `<= 0` の場合、PicoClaw はデフォルトのタイムアウト(120秒)を使用します。
|
||||
|
||||
**3. API キーの取得**
|
||||
|
||||
- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||
- **Web 検索**(任意): [Tavily](https://tavily.com) - AI エージェント向けに最適化 (月 1000 リクエスト) · [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
|
||||
|
||||
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
|
||||
|
||||
**4. チャット**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "What is 2+2?"
|
||||
```
|
||||
|
||||
これだけです!2 分で AI アシスタントが動きます。
|
||||
|
||||
---
|
||||
|
||||
## 💬 チャットアプリ
|
||||
|
||||
Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話できます
|
||||
|
||||
| チャネル | セットアップ |
|
||||
|---------|------------|
|
||||
| **Telegram** | 簡単(トークンのみ) |
|
||||
| **Discord** | 簡単(Bot トークン + Intents) |
|
||||
| **QQ** | 簡単(AppID + AppSecret) |
|
||||
| **DingTalk** | 普通(アプリ認証情報) |
|
||||
| **LINE** | 普通(認証情報 + Webhook URL) |
|
||||
| **WeCom AI Bot** | 普通(Token + AES キー) |
|
||||
|
||||
<details>
|
||||
<summary><b>Telegram</b>(推奨)</summary>
|
||||
|
||||
**1. Bot を作成**
|
||||
|
||||
- Telegram を開き、`@BotFather` を検索
|
||||
- `/newbot` を送信、プロンプトに従う
|
||||
- トークンをコピー
|
||||
|
||||
**2. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> ユーザー ID は Telegram の `@userinfobot` から取得できます。
|
||||
|
||||
**3. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary><b>Discord</b></summary>
|
||||
|
||||
**1. Bot を作成**
|
||||
- https://discord.com/developers/applications にアクセス
|
||||
- アプリケーションを作成 → Bot → Add Bot
|
||||
- Bot トークンをコピー
|
||||
|
||||
**2. Intents を有効化**
|
||||
- Bot の設定画面で **MESSAGE CONTENT INTENT** を有効化
|
||||
- (任意)**SERVER MEMBERS INTENT** も有効化
|
||||
|
||||
**3. ユーザー ID を取得**
|
||||
- Discord 設定 → 詳細設定 → **開発者モード** を有効化
|
||||
- 自分のアバターを右クリック → **ユーザーIDをコピー**
|
||||
|
||||
**4. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_BOT_TOKEN",
|
||||
"allow_from": ["YOUR_USER_ID"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**5. Bot を招待**
|
||||
- OAuth2 → URL Generator
|
||||
- Scopes: `bot`
|
||||
- Bot Permissions: `Send Messages`, `Read Message History`
|
||||
- 生成された招待 URL を開き、サーバーに Bot を追加
|
||||
|
||||
**6. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>QQ</b></summary>
|
||||
|
||||
**1. Bot を作成**
|
||||
|
||||
- [QQ オープンプラットフォーム](https://q.qq.com/#) にアクセス
|
||||
- アプリケーションを作成 → **AppID** と **AppSecret** を取得
|
||||
|
||||
**2. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"qq": {
|
||||
"enabled": true,
|
||||
"app_id": "YOUR_APP_ID",
|
||||
"app_secret": "YOUR_APP_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `allow_from` を空にすると全ユーザーを許可、QQ番号を指定してアクセス制限可能。
|
||||
|
||||
**3. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>DingTalk</b></summary>
|
||||
|
||||
**1. Bot を作成**
|
||||
|
||||
- [オープンプラットフォーム](https://open.dingtalk.com/) にアクセス
|
||||
- 内部アプリを作成
|
||||
- Client ID と Client Secret をコピー
|
||||
|
||||
**2. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"dingtalk": {
|
||||
"enabled": true,
|
||||
"client_id": "YOUR_CLIENT_ID",
|
||||
"client_secret": "YOUR_CLIENT_SECRET",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> `allow_from` を空にすると全ユーザーを許可、ユーザーIDを指定してアクセス制限可能。
|
||||
|
||||
**3. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>LINE</b></summary>
|
||||
|
||||
**1. LINE 公式アカウントを作成**
|
||||
|
||||
- [LINE Developers Console](https://developers.line.biz/) にアクセス
|
||||
- プロバイダーを作成 → Messaging API チャネルを作成
|
||||
- **チャネルシークレット** と **チャネルアクセストークン** をコピー
|
||||
|
||||
**2. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"line": {
|
||||
"enabled": true,
|
||||
"channel_secret": "YOUR_CHANNEL_SECRET",
|
||||
"channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
|
||||
"webhook_path": "/webhook/line",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Webhook URL を設定**
|
||||
|
||||
LINE の Webhook には HTTPS が必要です。リバースプロキシまたはトンネルを使用してください:
|
||||
|
||||
```bash
|
||||
# ngrok の例
|
||||
ngrok http 18790
|
||||
```
|
||||
|
||||
LINE Developers Console で Webhook URL を `https://あなたのドメイン/webhook/line` に設定し、**Webhook の利用** を有効にしてください。
|
||||
|
||||
> **注意**: LINE の Webhook は共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は Gateway のポートを公開するか、リバースプロキシを設定してください。
|
||||
|
||||
**4. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
> グループチャットでは @メンション時のみ応答します。返信は元メッセージを引用する形式です。
|
||||
|
||||
> **Docker Compose**: Gateway HTTP サーバーは共有の `127.0.0.1:18790` で Webhook を提供します。ホストからアクセスするには `picoclaw-gateway` サービスに `ports: ["18790:18790"]` を追加してください。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>WeCom (企業微信)</b></summary>
|
||||
|
||||
PicoClaw は3種類の WeCom 統合をサポートしています:
|
||||
|
||||
**オプション1: WeCom Bot (ロボット)** - 簡単な設定、グループチャット対応
|
||||
**オプション2: WeCom App (カスタムアプリ)** - より多機能、アクティブメッセージング対応、プライベートチャットのみ
|
||||
**オプション3: WeCom AI Bot (スマートボット)** - 公式 AI Bot、ストリーミング返信、グループ・プライベート両対応
|
||||
|
||||
詳細な設定手順は [WeCom AI Bot Configuration Guide](docs/channels/wecom/wecom_aibot/README.zh.md) を参照してください。
|
||||
|
||||
**クイックセットアップ - WeCom Bot:**
|
||||
|
||||
**1. ボットを作成**
|
||||
|
||||
* WeCom 管理コンソール → グループチャット → グループボットを追加
|
||||
* Webhook URL をコピー(形式: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
|
||||
|
||||
**2. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
|
||||
"webhook_path": "/webhook/wecom",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> **注意**: WeCom Bot の Webhook 受信は共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は Gateway のポートを公開するか、HTTPS 用のリバースプロキシを設定してください。
|
||||
```
|
||||
|
||||
**クイックセットアップ - WeCom App:**
|
||||
|
||||
**1. アプリを作成**
|
||||
|
||||
* WeCom 管理コンソール → アプリ管理 → アプリを作成
|
||||
* **AgentId** と **Secret** をコピー
|
||||
* "マイ会社" ページで **CorpID** をコピー
|
||||
|
||||
**2. メッセージ受信を設定**
|
||||
|
||||
* アプリ詳細で "メッセージを受信" → "APIを設定" をクリック
|
||||
* URL を `http://your-server:18790/webhook/wecom-app` に設定
|
||||
* **Token** と **EncodingAESKey** を生成
|
||||
|
||||
**3. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom_app": {
|
||||
"enabled": true,
|
||||
"corp_id": "wwxxxxxxxxxxxxxxxx",
|
||||
"corp_secret": "YOUR_CORP_SECRET",
|
||||
"agent_id": 1000002,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_ENCODING_AES_KEY",
|
||||
"webhook_path": "/webhook/wecom-app",
|
||||
"allow_from": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**4. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
> **注意**: WeCom App の Webhook コールバックは共有の Gateway HTTP サーバー(デフォルト: `127.0.0.1:18790`)で提供されます。ホストからアクセスする場合は HTTPS 用のリバースプロキシを設定してください。
|
||||
|
||||
**クイックセットアップ - WeCom AI Bot:**
|
||||
|
||||
**1. AI Bot を作成**
|
||||
|
||||
* WeCom 管理コンソール → アプリ管理 → AI Bot
|
||||
* コールバック URL を設定: `http://your-server:18791/webhook/wecom-aibot`
|
||||
* **Token** をコピーし、**EncodingAESKey** を生成
|
||||
|
||||
**2. 設定**
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"wecom_aibot": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_TOKEN",
|
||||
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
|
||||
"webhook_path": "/webhook/wecom-aibot",
|
||||
"allow_from": [],
|
||||
"welcome_message": "こんにちは!何かお手伝いできますか?"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. 起動**
|
||||
|
||||
```bash
|
||||
picoclaw gateway
|
||||
```
|
||||
|
||||
> **注意**: WeCom AI Bot はストリーミングプルプロトコルを使用 — 返信タイムアウトの心配なし。長時間タスク(>30秒)は自動的に `response_url` によるプッシュ配信に切り替わります。
|
||||
|
||||
</details>
|
||||
|
||||
## ⚙️ 設定
|
||||
|
||||
設定ファイル: `~/.picoclaw/config.json`
|
||||
|
||||
### 環境変数
|
||||
|
||||
環境変数を使用してデフォルトのパスを上書きできます。これは、ポータブルインストール、コンテナ化されたデプロイメント、または picoclaw をシステムサービスとして実行する場合に便利です。これらの変数は独立しており、異なるパスを制御します。
|
||||
|
||||
| 変数 | 説明 | デフォルトパス |
|
||||
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PICOCLAW_CONFIG` | 設定ファイルへのパスを上書きします。これにより、picoclaw は他のすべての場所を無視して、指定された `config.json` をロードします。 | `~/.picoclaw/config.json` |
|
||||
| `PICOCLAW_HOME` | picoclaw データのルートディレクトリを上書きします。これにより、`workspace` やその他のデータディレクトリのデフォルトの場所が変更されます。 | `~/.picoclaw` |
|
||||
|
||||
**例:**
|
||||
|
||||
```bash
|
||||
# 特定の設定ファイルを使用して picoclaw を実行する
|
||||
# ワークスペースのパスはその設定ファイル内から読み込まれます
|
||||
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||
|
||||
# すべてのデータを /opt/picoclaw に保存して picoclaw を実行する
|
||||
# 設定はデフォルトの ~/.picoclaw/config.json からロードされます
|
||||
# ワークスペースは /opt/picoclaw/workspace に作成されます
|
||||
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||
|
||||
# 両方を使用して完全にカスタマイズされたセットアップを行う
|
||||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||
```
|
||||
|
||||
### ワークスペース構成
|
||||
|
||||
PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します:
|
||||
|
||||
```
|
||||
~/.picoclaw/workspace/
|
||||
├── sessions/ # 会話セッションと履歴
|
||||
├── memory/ # 長期メモリ(MEMORY.md)
|
||||
├── state/ # 永続状態(最後のチャネルなど)
|
||||
├── cron/ # スケジュールジョブデータベース
|
||||
├── skills/ # カスタムスキル
|
||||
├── AGENT.md # 構造化されたエージェント定義とシステムプロンプト
|
||||
├── HEARTBEAT.md # 定期タスクプロンプト(30分ごとに確認)
|
||||
├── SOUL.md # エージェントのソウル
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 🔒 セキュリティサンドボックス
|
||||
|
||||
PicoClaw はデフォルトでサンドボックス環境で実行されます。エージェントは設定されたワークスペース内のファイルにのみアクセスし、コマンドを実行できます。
|
||||
|
||||
#### デフォルト設定
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| オプション | デフォルト | 説明 |
|
||||
|-----------|-----------|------|
|
||||
| `workspace` | `~/.picoclaw/workspace` | エージェントの作業ディレクトリ |
|
||||
| `restrict_to_workspace` | `true` | ファイル/コマンドアクセスをワークスペースに制限 |
|
||||
|
||||
#### 保護対象ツール
|
||||
|
||||
`restrict_to_workspace: true` の場合、以下のツールがサンドボックス化されます:
|
||||
|
||||
| ツール | 機能 | 制限 |
|
||||
|-------|------|------|
|
||||
| `read_file` | ファイル読み込み | ワークスペース内のファイルのみ |
|
||||
| `write_file` | ファイル書き込み | ワークスペース内のファイルのみ |
|
||||
| `list_dir` | ディレクトリ一覧 | ワークスペース内のディレクトリのみ |
|
||||
| `edit_file` | ファイル編集 | ワークスペース内のファイルのみ |
|
||||
| `append_file` | ファイル追記 | ワークスペース内のファイルのみ |
|
||||
| `exec` | コマンド実行 | コマンドパスはワークスペース内である必要あり |
|
||||
|
||||
#### exec ツールの追加保護
|
||||
|
||||
`restrict_to_workspace: false` でも、`exec` ツールは以下の危険なコマンドをブロックします:
|
||||
|
||||
- `rm -rf`, `del /f`, `rmdir /s` — 一括削除
|
||||
- `format`, `mkfs`, `diskpart` — ディスクフォーマット
|
||||
- `dd if=` — ディスクイメージング
|
||||
- `/dev/sd[a-z]` への書き込み — 直接ディスク書き込み
|
||||
- `shutdown`, `reboot`, `poweroff` — システムシャットダウン
|
||||
- フォークボム `:(){ :|:& };:`
|
||||
|
||||
#### エラー例
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (path outside working dir)}
|
||||
```
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
|
||||
```
|
||||
|
||||
#### 制限の無効化(セキュリティリスク)
|
||||
|
||||
エージェントにワークスペース外のパスへのアクセスが必要な場合:
|
||||
|
||||
**方法1: 設定ファイル**
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"restrict_to_workspace": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**方法2: 環境変数**
|
||||
```bash
|
||||
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
||||
```
|
||||
|
||||
> ⚠️ **警告**: この制限を無効にすると、エージェントはシステム上の任意のパスにアクセスできるようになります。制御された環境でのみ慎重に使用してください。
|
||||
|
||||
#### セキュリティ境界の一貫性
|
||||
|
||||
`restrict_to_workspace` 設定は、すべての実行パスで一貫して適用されます:
|
||||
|
||||
| 実行パス | セキュリティ境界 |
|
||||
|---------|-----------------|
|
||||
| メインエージェント | `restrict_to_workspace` ✅ |
|
||||
| サブエージェント / Spawn | 同じ制限を継承 ✅ |
|
||||
| ハートビートタスク | 同じ制限を継承 ✅ |
|
||||
|
||||
すべてのパスで同じワークスペース制限が適用されます — サブエージェントやスケジュールタスクを通じてセキュリティ境界をバイパスする方法はありません。
|
||||
|
||||
### ハートビート(定期タスク)
|
||||
|
||||
PicoClaw は自動的に定期タスクを実行できます。ワークスペースに `HEARTBEAT.md` ファイルを作成します:
|
||||
|
||||
```markdown
|
||||
# 定期タスク
|
||||
|
||||
- 重要なメールをチェック
|
||||
- 今後の予定を確認
|
||||
- 天気予報をチェック
|
||||
```
|
||||
|
||||
エージェントは30分ごと(設定可能)にこのファイルを読み込み、利用可能なツールを使ってタスクを実行します。
|
||||
|
||||
#### spawn で非同期タスク実行
|
||||
|
||||
時間のかかるタスク(Web検索、API呼び出し)には `spawn` ツールを使って**サブエージェント**を作成します:
|
||||
|
||||
```markdown
|
||||
# 定期タスク
|
||||
|
||||
## クイックタスク(直接応答)
|
||||
- 現在時刻を報告
|
||||
|
||||
## 長時間タスク(spawn で非同期)
|
||||
- AIニュースを検索して要約
|
||||
- メールをチェックして重要なメッセージを報告
|
||||
```
|
||||
|
||||
**主な特徴:**
|
||||
|
||||
| 機能 | 説明 |
|
||||
|------|------|
|
||||
| **spawn** | 非同期サブエージェントを作成、ハートビートをブロックしない |
|
||||
| **独立コンテキスト** | サブエージェントは独自のコンテキストを持ち、セッション履歴なし |
|
||||
| **message ツール** | サブエージェントは message ツールで直接ユーザーと通信 |
|
||||
| **非ブロッキング** | spawn 後、ハートビートは次のタスクへ継続 |
|
||||
|
||||
#### サブエージェントの通信方法
|
||||
|
||||
```
|
||||
ハートビート発動
|
||||
↓
|
||||
エージェントが HEARTBEAT.md を読む
|
||||
↓
|
||||
長いタスク: spawn サブエージェント
|
||||
↓ ↓
|
||||
次のタスクへ継続 サブエージェントが独立して動作
|
||||
↓ ↓
|
||||
全タスク完了 message ツールを使用
|
||||
↓ ↓
|
||||
HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
||||
```
|
||||
|
||||
サブエージェントはツール(message、web_search など)にアクセスでき、メインエージェントを経由せずにユーザーと通信できます。
|
||||
|
||||
**設定:**
|
||||
|
||||
```json
|
||||
{
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| オプション | デフォルト | 説明 |
|
||||
|-----------|-----------|------|
|
||||
| `enabled` | `true` | ハートビートの有効/無効 |
|
||||
| `interval` | `30` | チェック間隔(分)、最小5分 |
|
||||
|
||||
**環境変数:**
|
||||
- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化
|
||||
- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔変更
|
||||
|
||||
### プロバイダー
|
||||
|
||||
> [!NOTE]
|
||||
> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、あらゆるチャンネルからの音声メッセージがエージェントレベルで自動的に文字起こしされます。
|
||||
|
||||
| プロバイダー | 用途 | 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) |
|
||||
| `groq` | LLM + **音声文字起こし**(Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
| `cerebras` | LLM(Cerebras 直接) | [cerebras.ai](https://cerebras.ai) |
|
||||
|
||||
### 基本設定
|
||||
|
||||
1. **設定ファイルの作成:**
|
||||
|
||||
```bash
|
||||
cp config.example.json config/config.json
|
||||
```
|
||||
|
||||
2. **設定の編集:**
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "sk-or-v1-..."
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "YOUR_DISCORD_BOT_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **実行**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "Hello"
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>完全な設定例</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "sk-or-v1-xxx"
|
||||
},
|
||||
"groq": {
|
||||
"api_key": "gsk_xxx"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "123456:ABC...",
|
||||
"allow_from": ["123456789"]
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "",
|
||||
"allow_from": [""]
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": false
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"search": {
|
||||
"api_key": "BSA..."
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### モデル設定 (model_list)
|
||||
|
||||
> **新機能!** PicoClaw は現在 **モデル中心** の設定アプローチを採用しています。`ベンダー/モデル` 形式(例: `zhipu/glm-4.7`)を指定するだけで、新しいプロバイダーを追加できます—**コードの変更は一切不要!**
|
||||
|
||||
この設計は、柔軟なプロバイダー選択による **マルチエージェントサポート** も可能にします:
|
||||
|
||||
- **異なるエージェント、異なるプロバイダー** : 各エージェントは独自の LLM プロバイダーを使用可能
|
||||
- **フォールバックモデル** : 耐障性のため、プライマリモデルとフォールバックモデルを設定可能
|
||||
- **ロードバランシング** : 複数のエンドポイントにリクエストを分散
|
||||
- **集中設定管理** : すべてのプロバイダーを一箇所で管理
|
||||
|
||||
#### 📋 サポートされているすべてのベンダー
|
||||
|
||||
| ベンダー | `model` プレフィックス | デフォルト API Base | プロトコル | API キー |
|
||||
|-------------|-----------------|---------------------|----------|---------|
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [キーを取得](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [キーを取得](https://console.anthropic.com) |
|
||||
| **Zhipu 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 (Alibaba)** | `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 | ローカル(キー不要) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) |
|
||||
| **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) |
|
||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [キーを取得](https://www.byteplus.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 | カスタム | 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### ベンダー別の例
|
||||
|
||||
**OpenAI**
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**VolcEngine (Doubao)**
|
||||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**Zhipu AI (GLM)**
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
```
|
||||
|
||||
**Anthropic (OAuth使用)**
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"auth_method": "oauth"
|
||||
}
|
||||
```
|
||||
> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
|
||||
|
||||
**カスタムプロキシ/API**
|
||||
```json
|
||||
{
|
||||
"model_name": "my-custom-model",
|
||||
"model": "openai/custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_key": "sk-...",
|
||||
"request_timeout": 300
|
||||
}
|
||||
```
|
||||
|
||||
#### ロードバランシング
|
||||
|
||||
同じモデル名で複数のエンドポイントを設定すると、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` 設定は**非推奨**ですが、後方互換性のためにサポートされています。
|
||||
|
||||
**旧設定(非推奨):**
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"zhipu": {
|
||||
"api_key": "your-key",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**新設定(推奨):**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "glm-4.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
詳細な移行ガイドは、[docs/migration/model-list-migration.md](docs/migration/model-list-migration.md) を参照してください。
|
||||
|
||||
## CLI リファレンス
|
||||
|
||||
| コマンド | 説明 |
|
||||
>>>>>>> refactor/agent
|
||||
|---------|------|
|
||||
| 🐳 [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 など |
|
||||
|
||||
@@ -708,6 +708,649 @@ Connect Picoclaw to the Agent Social Network simply by sending a single message
|
||||
|
||||
**Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
Config file: `~/.picoclaw/config.json`
|
||||
|
||||
### Environment Variables
|
||||
|
||||
You can override default paths using environment variables. This is useful for portable installations, containerized deployments, or running picoclaw as a system service. These variables are independent and control different paths.
|
||||
|
||||
| Variable | Description | Default Path |
|
||||
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PICOCLAW_CONFIG` | Overrides the path to the configuration file. This directly tells picoclaw which `config.json` to load, ignoring all other locations. | `~/.picoclaw/config.json` |
|
||||
| `PICOCLAW_HOME` | Overrides the root directory for picoclaw data. This changes the default location of the `workspace` and other data directories. | `~/.picoclaw` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Run picoclaw using a specific config file
|
||||
# The workspace path will be read from within that config file
|
||||
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||
|
||||
# Run picoclaw with all its data stored in /opt/picoclaw
|
||||
# Config will be loaded from the default ~/.picoclaw/config.json
|
||||
# Workspace will be created at /opt/picoclaw/workspace
|
||||
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||
|
||||
# Use both for a fully customized setup
|
||||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||
```
|
||||
|
||||
### Workspace Layout
|
||||
|
||||
PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspace`):
|
||||
|
||||
```
|
||||
~/.picoclaw/workspace/
|
||||
├── sessions/ # Conversation sessions and history
|
||||
├── memory/ # Long-term memory (MEMORY.md)
|
||||
├── state/ # Persistent state (last channel, etc.)
|
||||
├── cron/ # Scheduled jobs database
|
||||
├── skills/ # Workspace-specific skills
|
||||
├── AGENT.md # Structured agent definition and system prompt
|
||||
├── SOUL.md # Agent soul
|
||||
├── USER.md # User profile and preferences for this workspace
|
||||
├── HEARTBEAT.md # Periodic task prompts (checked every 30 min)
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Skill Sources
|
||||
|
||||
By default, skills are loaded from:
|
||||
|
||||
1. `~/.picoclaw/workspace/skills` (workspace)
|
||||
2. `~/.picoclaw/skills` (global)
|
||||
3. `<current-working-directory>/skills` (builtin)
|
||||
|
||||
For advanced/test setups, you can override the builtin skills root with:
|
||||
|
||||
```bash
|
||||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||
```
|
||||
|
||||
### Unified Command Execution Policy
|
||||
|
||||
- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`.
|
||||
- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup.
|
||||
- Unknown slash command (for example `/foo`) passes through to normal LLM processing.
|
||||
- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing.
|
||||
### 🔒 Security Sandbox
|
||||
|
||||
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
|
||||
|
||||
#### Default Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ----------------------- | ----------------------- | ----------------------------------------- |
|
||||
| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
|
||||
| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
|
||||
|
||||
#### Protected Tools
|
||||
|
||||
When `restrict_to_workspace: true`, the following tools are sandboxed:
|
||||
|
||||
| Tool | Function | Restriction |
|
||||
| ------------- | ---------------- | -------------------------------------- |
|
||||
| `read_file` | Read files | Only files within workspace |
|
||||
| `write_file` | Write files | Only files within workspace |
|
||||
| `list_dir` | List directories | Only directories within workspace |
|
||||
| `edit_file` | Edit files | Only files within workspace |
|
||||
| `append_file` | Append to files | Only files within workspace |
|
||||
| `exec` | Execute commands | Command paths must be within workspace |
|
||||
|
||||
#### Additional Exec Protection
|
||||
|
||||
Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands:
|
||||
|
||||
* `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion
|
||||
* `format`, `mkfs`, `diskpart` — Disk formatting
|
||||
* `dd if=` — Disk imaging
|
||||
* Writing to `/dev/sd[a-z]` — Direct disk writes
|
||||
* `shutdown`, `reboot`, `poweroff` — System shutdown
|
||||
* Fork bomb `:(){ :|:& };:`
|
||||
|
||||
#### Error Examples
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (path outside working dir)}
|
||||
```
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
|
||||
```
|
||||
|
||||
#### Disabling Restrictions (Security Risk)
|
||||
|
||||
If you need the agent to access paths outside the workspace:
|
||||
|
||||
**Method 1: Config file**
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"restrict_to_workspace": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Method 2: Environment variable**
|
||||
|
||||
```bash
|
||||
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
||||
```
|
||||
|
||||
> ⚠️ **Warning**: Disabling this restriction allows the agent to access any path on your system. Use with caution in controlled environments only.
|
||||
|
||||
#### Security Boundary Consistency
|
||||
|
||||
The `restrict_to_workspace` setting applies consistently across all execution paths:
|
||||
|
||||
| Execution Path | Security Boundary |
|
||||
| ---------------- | ---------------------------- |
|
||||
| Main Agent | `restrict_to_workspace` ✅ |
|
||||
| Subagent / Spawn | Inherits same restriction ✅ |
|
||||
| Heartbeat tasks | Inherits same restriction ✅ |
|
||||
|
||||
All paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks.
|
||||
|
||||
### Heartbeat (Periodic Tasks)
|
||||
|
||||
PicoClaw can perform periodic tasks automatically. Create a `HEARTBEAT.md` file in your workspace:
|
||||
|
||||
```markdown
|
||||
# Periodic Tasks
|
||||
|
||||
- Check my email for important messages
|
||||
- Review my calendar for upcoming events
|
||||
- Check the weather forecast
|
||||
```
|
||||
|
||||
The agent will read this file every 30 minutes (configurable) and execute any tasks using available tools.
|
||||
|
||||
#### Async Tasks with Spawn
|
||||
|
||||
For long-running tasks (web search, API calls), use the `spawn` tool to create a **subagent**:
|
||||
|
||||
```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
|
||||
|
||||
**OpenAI**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**VolcEngine (Doubao)**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**智谱 AI (GLM)**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
```
|
||||
|
||||
**DeepSeek**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "deepseek-chat",
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**Anthropic (with API key)**
|
||||
|
||||
```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.
|
||||
|
||||
**Anthropic Messages API (native format)**
|
||||
|
||||
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` protocol when:
|
||||
> - Using third-party proxies that only support Anthropic's native `/v1/messages` endpoint (not OpenAI-compatible `/v1/chat/completions`)
|
||||
> - Connecting to services like MiniMax, Synthetic that require Anthropic's native message format
|
||||
> - The existing `anthropic` protocol returns 404 errors (indicating the endpoint doesn't support OpenAI-compatible format)
|
||||
>
|
||||
> **Note:** The `anthropic` protocol uses OpenAI-compatible format (`/v1/chat/completions`), while `anthropic-messages` uses Anthropic's native format (`/v1/messages`). Choose based on your endpoint's supported format.
|
||||
|
||||
**Ollama (local)**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "llama3",
|
||||
"model": "ollama/llama3"
|
||||
}
|
||||
```
|
||||
|
||||
**Custom Proxy/API**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "my-custom-model",
|
||||
"model": "openai/custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_key": "sk-...",
|
||||
"request_timeout": 300
|
||||
}
|
||||
```
|
||||
|
||||
**LiteLLM Proxy**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "lite-gpt4",
|
||||
"model": "litellm/lite-gpt4",
|
||||
"api_base": "http://localhost:4000/v1",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
PicoClaw strips only the outer `litellm/` prefix before sending the request, so proxy aliases like `litellm/lite-gpt4` send `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`.
|
||||
|
||||
#### 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.
|
||||
|
||||
**Old Config (deprecated):**
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"zhipu": {
|
||||
"api_key": "your-key",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**New Config (recommended):**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "glm-4.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For detailed migration guide, see [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
|
||||
|
||||
### Provider Architecture
|
||||
|
||||
PicoClaw routes providers by protocol family:
|
||||
|
||||
- OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints.
|
||||
- Anthropic protocol: Claude-native API behavior.
|
||||
- Codex/OAuth path: 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</b></summary>
|
||||
|
||||
**1. Get API key and base URL**
|
||||
|
||||
* Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||
|
||||
**2. Configure**
|
||||
|
||||
```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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Run**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "Hello"
|
||||
```
|
||||
|
||||
</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"]
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "",
|
||||
"allow_from": [""]
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": false,
|
||||
"bridge_url": "ws://localhost:3001",
|
||||
"use_native": false,
|
||||
"session_store_path": "",
|
||||
"allow_from": []
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
},
|
||||
"qq": {
|
||||
"enabled": false,
|
||||
"app_id": "",
|
||||
"app_secret": "",
|
||||
"allow_from": []
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "BSA...",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
},
|
||||
"perplexity": {
|
||||
"enabled": false,
|
||||
"api_key": "",
|
||||
"max_results": 5
|
||||
},
|
||||
"searxng": {
|
||||
"enabled": false,
|
||||
"base_url": "http://localhost:8888",
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 🖥️ CLI Reference
|
||||
|
||||
| Command | Description |
|
||||
|
||||
+543
@@ -214,6 +214,7 @@ Conecte o PicoClaw à Rede Social de Agentes simplesmente enviando uma única me
|
||||
|
||||
**Leia `https://clawdchat.ai/skill.md` e siga as instruções para entrar no [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
<<<<<<< HEAD
|
||||
## 🖥️ Referência CLI
|
||||
|
||||
| Comando | Descrição |
|
||||
@@ -233,6 +234,548 @@ Conecte o PicoClaw à Rede Social de Agentes simplesmente enviando uma única me
|
||||
| `picoclaw migrate` | Migrar dados de versões anteriores |
|
||||
| `picoclaw auth login` | Autenticar com provedores |
|
||||
| `picoclaw model` | Ver ou trocar o modelo padrão |
|
||||
=======
|
||||
## ⚙️ Configuração Detalhada
|
||||
|
||||
Arquivo de configuração: `~/.picoclaw/config.json`
|
||||
|
||||
### Variáveis de Ambiente
|
||||
|
||||
Você pode substituir os caminhos padrão usando variáveis de ambiente. Isso é útil para instalações portáteis, implantações em contêineres ou para executar o picoclaw como um serviço do sistema. Essas variáveis são independentes e controlam caminhos diferentes.
|
||||
|
||||
| Variável | Descrição | Caminho Padrão |
|
||||
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PICOCLAW_CONFIG` | Substitui o caminho para o arquivo de configuração. Isso informa diretamente ao picoclaw qual `config.json` carregar, ignorando todos os outros locais. | `~/.picoclaw/config.json` |
|
||||
| `PICOCLAW_HOME` | Substitui o diretório raiz dos dados do picoclaw. Isso altera o local padrão do `workspace` e de outros diretórios de dados. | `~/.picoclaw` |
|
||||
|
||||
**Exemplos:**
|
||||
|
||||
```bash
|
||||
# Executar o picoclaw usando um arquivo de configuração específico
|
||||
# O caminho do workspace será lido de dentro desse arquivo de configuração
|
||||
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||
|
||||
# Executar o picoclaw com todos os seus dados armazenados em /opt/picoclaw
|
||||
# A configuração será carregada do ~/.picoclaw/config.json padrão
|
||||
# O workspace será criado em /opt/picoclaw/workspace
|
||||
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||
|
||||
# Use ambos para uma configuração totalmente personalizada
|
||||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||
```
|
||||
|
||||
### Estrutura do Workspace
|
||||
|
||||
O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/workspace`):
|
||||
|
||||
```
|
||||
~/.picoclaw/workspace/
|
||||
├── sessions/ # Sessoes de conversa e historico
|
||||
├── memory/ # Memoria de longo prazo (MEMORY.md)
|
||||
├── state/ # Estado persistente (ultimo canal, etc.)
|
||||
├── cron/ # Banco de dados de tarefas agendadas
|
||||
├── skills/ # Skills personalizadas
|
||||
├── AGENT.md # Definicao estruturada do agente e prompt do sistema
|
||||
├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min)
|
||||
├── SOUL.md # Alma do Agente
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 🔒 Sandbox de Segurança
|
||||
|
||||
O PicoClaw roda em um ambiente sandbox por padrão. O agente so pode acessar arquivos e executar comandos dentro do workspace configurado.
|
||||
|
||||
#### Configuração Padrão
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Opção | Padrão | Descrição |
|
||||
|-------|--------|-----------|
|
||||
| `workspace` | `~/.picoclaw/workspace` | Diretório de trabalho do agente |
|
||||
| `restrict_to_workspace` | `true` | Restringir acesso de arquivos/comandos ao workspace |
|
||||
|
||||
#### Ferramentas Protegidas
|
||||
|
||||
Quando `restrict_to_workspace: true`, as seguintes ferramentas são restritas ao sandbox:
|
||||
|
||||
| Ferramenta | Função | Restrição |
|
||||
|------------|--------|-----------|
|
||||
| `read_file` | Ler arquivos | Apenas arquivos dentro do workspace |
|
||||
| `write_file` | Escrever arquivos | Apenas arquivos dentro do workspace |
|
||||
| `list_dir` | Listar diretorios | Apenas diretorios dentro do workspace |
|
||||
| `edit_file` | Editar arquivos | Apenas arquivos dentro do workspace |
|
||||
| `append_file` | Adicionar a arquivos | Apenas arquivos dentro do workspace |
|
||||
| `exec` | Executar comandos | Caminhos dos comandos devem estar dentro do workspace |
|
||||
|
||||
#### Proteção Adicional do Exec
|
||||
|
||||
Mesmo com `restrict_to_workspace: false`, a ferramenta `exec` bloqueia estes comandos perigosos:
|
||||
|
||||
* `rm -rf`, `del /f`, `rmdir /s` — Exclusão em massa
|
||||
* `format`, `mkfs`, `diskpart` — Formatação de disco
|
||||
* `dd if=` — Criação de imagem de disco
|
||||
* Escrita em `/dev/sd[a-z]` — Escrita direta no disco
|
||||
* `shutdown`, `reboot`, `poweroff` — Desligamento do sistema
|
||||
* Fork bomb `:(){ :|:& };:`
|
||||
|
||||
#### Exemplos de Erro
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (path outside working dir)}
|
||||
```
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
|
||||
```
|
||||
|
||||
#### Desabilitar Restrições (Risco de Segurança)
|
||||
|
||||
Se você precisa que o agente acesse caminhos fora do workspace:
|
||||
|
||||
**Método 1: Arquivo de configuração**
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"restrict_to_workspace": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Método 2: Variável de ambiente**
|
||||
|
||||
```bash
|
||||
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
||||
```
|
||||
|
||||
> ⚠️ **Aviso**: Desabilitar esta restrição permite que o agente acesse qualquer caminho no seu sistema. Use com cuidado apenas em ambientes controlados.
|
||||
|
||||
#### Consistência do Limite de Segurança
|
||||
|
||||
A configuração `restrict_to_workspace` se aplica consistentemente em todos os caminhos de execução:
|
||||
|
||||
| Caminho de Execução | Limite de Segurança |
|
||||
|----------------------|---------------------|
|
||||
| Agente Principal | `restrict_to_workspace` ✅ |
|
||||
| Subagente / Spawn | Herda a mesma restrição ✅ |
|
||||
| Tarefas Heartbeat | Herda a mesma restrição ✅ |
|
||||
|
||||
Todos os caminhos compartilham a mesma restrição de workspace — nao há como contornar o limite de segurança por meio de subagentes ou tarefas agendadas.
|
||||
|
||||
### Heartbeat (Tarefas Periódicas)
|
||||
|
||||
O PicoClaw pode executar tarefas periódicas automaticamente. Crie um arquivo `HEARTBEAT.md` no seu workspace:
|
||||
|
||||
```markdown
|
||||
# Tarefas Periodicas
|
||||
|
||||
- Verificar meu email para mensagens importantes
|
||||
- Revisar minha agenda para proximos eventos
|
||||
- Verificar a previsao do tempo
|
||||
```
|
||||
|
||||
O agente lerá este arquivo a cada 30 minutos (configurável) e executará as tarefas usando as ferramentas disponíveis.
|
||||
|
||||
#### Tarefas Assincronas com Spawn
|
||||
|
||||
Para tarefas de longa duração (busca web, chamadas de API), use a ferramenta `spawn` para criar um **subagente**:
|
||||
|
||||
```markdown
|
||||
# Tarefas Periódicas
|
||||
|
||||
## Tarefas Rápidas (resposta direta)
|
||||
- Informar hora atual
|
||||
|
||||
## Tarefas Longas (usar spawn para async)
|
||||
- Buscar notícias de IA na web e resumir
|
||||
- Verificar email 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 |
|
||||
| **Ferramenta message** | Subagente se comunica diretamente com o usuário via ferramenta message |
|
||||
| **Não-bloqueante** | Após o spawn, o heartbeat continua para a próxima tarefa |
|
||||
|
||||
#### Como Funciona a Comunicação do Subagente
|
||||
|
||||
```
|
||||
Heartbeat dispara
|
||||
↓
|
||||
Agente lê HEARTBEAT.md
|
||||
↓
|
||||
Para 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
|
||||
```
|
||||
|
||||
O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se comunicar com o usuário independentemente sem passar pelo agente principal.
|
||||
|
||||
**Configuração:**
|
||||
|
||||
```json
|
||||
{
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Opção | Padrão | Descrição |
|
||||
|-------|--------|-----------|
|
||||
| `enabled` | `true` | Habilitar/desabilitar heartbeat |
|
||||
| `interval` | `30` | Intervalo de verificação em minutos (min: 5) |
|
||||
|
||||
**Variáveis de ambiente:**
|
||||
|
||||
* `PICOCLAW_HEARTBEAT_ENABLED=false` para desabilitar
|
||||
* `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo
|
||||
|
||||
### Provedores
|
||||
|
||||
> [!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.
|
||||
|
||||
| Provedor | Finalidade | Obter API Key |
|
||||
| --- | --- | --- |
|
||||
| `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](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` (Em teste) | LLM (recomendado, acesso a todos os modelos) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek` (Em teste) | LLM (DeepSeek direto) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `qwen` | Alibaba Qwen | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `cerebras` | Cerebras | [cerebras.ai](https://cerebras.ai) |
|
||||
| `groq` | LLM + **Transcrição de voz** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
|
||||
<details>
|
||||
<summary><b>Configuração Zhipu</b></summary>
|
||||
|
||||
**1. Obter API key**
|
||||
|
||||
* Obtenha a [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||
|
||||
**2. Configurar**
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"model": "glm-4.7",
|
||||
"max_tokens": 8192,
|
||||
"temperature": 0.7,
|
||||
"max_tool_iterations": 20
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"zhipu": {
|
||||
"api_key": "Sua API Key",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Executar**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "Ola, como vai?"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Exemplo de configuraçao completa</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "sk-or-v1-xxx"
|
||||
},
|
||||
"groq": {
|
||||
"api_key": "gsk_xxx"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "123456:ABC...",
|
||||
"allow_from": ["123456789"]
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "",
|
||||
"allow_from": [""]
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": false
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
},
|
||||
"qq": {
|
||||
"enabled": false,
|
||||
"app_id": "",
|
||||
"app_secret": "",
|
||||
"allow_from": []
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "BSA...",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Configuração de Modelo (model_list)
|
||||
|
||||
> **Novidade!** PicoClaw agora usa uma abordagem de configuração **centrada no modelo**. Basta especificar o formato `fornecedor/modelo` (ex: `zhipu/glm-4.7`) para adicionar novos provedores—**nenhuma alteração de código necessária!**
|
||||
|
||||
Este design também possibilita o **suporte multi-agent** com seleção flexível de provedores:
|
||||
|
||||
- **Diferentes agentes, diferentes provedores** : Cada agente pode usar seu próprio provedor LLM
|
||||
- **Modelos de fallback** : Configure modelos primários e de reserva para resiliência
|
||||
- **Balanceamento de carga** : Distribua solicitações entre múltiplos endpoints
|
||||
- **Configuração centralizada** : Gerencie todos os provedores em um só lugar
|
||||
|
||||
#### 📋 Todos os Fornecedores Suportados
|
||||
|
||||
| Fornecedor | Prefixo `model` | API Base Padrão | Protocolo | Chave API |
|
||||
|-------------|-----------------|------------------|----------|-----------|
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Obter Chave](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obter Chave](https://console.anthropic.com) |
|
||||
| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obter Chave](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obter Chave](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obter Chave](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obter Chave](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Obter Chave](https://platform.moonshot.cn) |
|
||||
| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obter Chave](https://dashscope.console.aliyun.com) |
|
||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Obter Chave](https://build.nvidia.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (sem chave necessária) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter Chave](https://openrouter.ai/keys) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obter Chave](https://www.byteplus.com) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obter Chave](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Obter Token](https://modelscope.cn/my/tokens) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
|
||||
#### Configuração Básica
|
||||
|
||||
```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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Exemplos por Fornecedor
|
||||
|
||||
**OpenAI**
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**VolcEngine (Doubao)**
|
||||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**Zhipu AI (GLM)**
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
```
|
||||
|
||||
**Anthropic (com OAuth)**
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"auth_method": "oauth"
|
||||
}
|
||||
```
|
||||
> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth.
|
||||
|
||||
**Proxy/API personalizada**
|
||||
```json
|
||||
{
|
||||
"model_name": "my-custom-model",
|
||||
"model": "openai/custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_key": "sk-...",
|
||||
"request_timeout": 300
|
||||
}
|
||||
```
|
||||
|
||||
#### Balanceamento de Carga
|
||||
|
||||
Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles:
|
||||
|
||||
```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á **descontinuada** mas ainda é suportada para compatibilidade reversa.
|
||||
|
||||
**Configuração Antiga (descontinuada):**
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"zhipu": {
|
||||
"api_key": "your-key",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Nova Configuração (recomendada):**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "glm-4.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Para o guia de migração detalhado, consulte [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
|
||||
|
||||
## 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 (para bots de chat) |
|
||||
| `picoclaw status` | Mostrar status |
|
||||
| `picoclaw cron list` | Listar todas as tarefas agendadas |
|
||||
| `picoclaw cron add ...` | Adicionar uma tarefa agendada |
|
||||
>>>>>>> refactor/agent
|
||||
|
||||
### Tarefas Agendadas / Lembretes
|
||||
|
||||
|
||||
+540
@@ -214,6 +214,7 @@ Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một
|
||||
|
||||
**Đọc `https://clawdchat.ai/skill.md` và làm theo hướng dẫn để tham gia [ClawdChat.ai](https://clawdchat.ai)**
|
||||
|
||||
<<<<<<< HEAD
|
||||
## 🖥️ Tham chiếu CLI
|
||||
|
||||
| Lệnh | Mô tả |
|
||||
@@ -233,6 +234,545 @@ Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một
|
||||
| `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 |
|
||||
=======
|
||||
## ⚙️ Cấu hình chi tiết
|
||||
|
||||
File cấu hình: `~/.picoclaw/config.json`
|
||||
|
||||
### Biến môi trường
|
||||
|
||||
Bạn có thể ghi đè các đường dẫn mặc định bằng cách sử dụng các biến môi trường. Điều này hữu ích cho việc cài đặt di động, triển khai container hóa hoặc chạy picoclaw như một dịch vụ hệ thống. Các biến này độc lập và kiểm soát các đường dẫn khác nhau.
|
||||
|
||||
| Biến | Mô tả | Đường dẫn mặc định |
|
||||
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PICOCLAW_CONFIG` | Ghi đè đường dẫn đến file cấu hình. Điều này trực tiếp yêu cầu picoclaw tải file `config.json` nào, bỏ qua tất cả các vị trí khác. | `~/.picoclaw/config.json` |
|
||||
| `PICOCLAW_HOME` | Ghi đè thư mục gốc cho dữ liệu picoclaw. Điều này thay đổi vị trí mặc định của `workspace` và các thư mục dữ liệu khác. | `~/.picoclaw` |
|
||||
|
||||
**Ví dụ:**
|
||||
|
||||
```bash
|
||||
# Chạy picoclaw bằng một file cấu hình cụ thể
|
||||
# Đường dẫn workspace sẽ được đọc từ trong file cấu hình đó
|
||||
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||
|
||||
# Chạy picoclaw với tất cả dữ liệu được lưu trữ trong /opt/picoclaw
|
||||
# Cấu hình sẽ được tải từ ~/.picoclaw/config.json mặc định
|
||||
# Workspace sẽ được tạo tại /opt/picoclaw/workspace
|
||||
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||
|
||||
# Sử dụng cả hai để có thiết lập tùy chỉnh hoàn toàn
|
||||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||
```
|
||||
|
||||
### Cấu trúc Workspace
|
||||
|
||||
PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`):
|
||||
|
||||
```
|
||||
~/.picoclaw/workspace/
|
||||
├── sessions/ # Phiên hội thoại và lịch sử
|
||||
├── memory/ # Bộ nhớ dài hạn (MEMORY.md)
|
||||
├── state/ # Trạng thái lưu trữ (kênh cuối cùng, v.v.)
|
||||
├── cron/ # Cơ sở dữ liệu tác vụ định kỳ
|
||||
├── skills/ # Kỹ năng tùy chỉnh
|
||||
├── AGENT.md # Định nghĩa agent có cấu trúc và system prompt
|
||||
├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút)
|
||||
├── SOUL.md # Tâm hồn/Tính cách Agent
|
||||
└── ...
|
||||
```
|
||||
|
||||
### 🔒 Hộp cát bảo mật (Security Sandbox)
|
||||
|
||||
PicoClaw chạy trong môi trường sandbox theo mặc định. Agent chỉ có thể truy cập file và thực thi lệnh trong phạm vi workspace.
|
||||
|
||||
#### Cấu hình mặc định
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"workspace": "~/.picoclaw/workspace",
|
||||
"restrict_to_workspace": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Tùy chọn | Mặc định | Mô tả |
|
||||
|----------|---------|-------|
|
||||
| `workspace` | `~/.picoclaw/workspace` | Thư mục làm việc của agent |
|
||||
| `restrict_to_workspace` | `true` | Giới hạn truy cập file/lệnh trong workspace |
|
||||
|
||||
#### Công cụ được bảo vệ
|
||||
|
||||
Khi `restrict_to_workspace: true`, các công cụ sau bị giới hạn trong sandbox:
|
||||
|
||||
| Công cụ | Chức năng | Giới hạn |
|
||||
|---------|----------|---------|
|
||||
| `read_file` | Đọc file | Chỉ file trong workspace |
|
||||
| `write_file` | Ghi file | Chỉ file trong workspace |
|
||||
| `list_dir` | Liệt kê thư mục | Chỉ thư mục trong workspace |
|
||||
| `edit_file` | Sửa file | Chỉ file trong workspace |
|
||||
| `append_file` | Thêm vào file | Chỉ file trong workspace |
|
||||
| `exec` | Thực thi lệnh | Đường dẫn lệnh phải trong workspace |
|
||||
|
||||
#### Bảo vệ bổ sung cho Exec
|
||||
|
||||
Ngay cả khi `restrict_to_workspace: false`, công cụ `exec` vẫn chặn các lệnh nguy hiểm sau:
|
||||
|
||||
* `rm -rf`, `del /f`, `rmdir /s` — Xóa hàng loạt
|
||||
* `format`, `mkfs`, `diskpart` — Định dạng ổ đĩa
|
||||
* `dd if=` — Tạo ảnh đĩa
|
||||
* Ghi vào `/dev/sd[a-z]` — Ghi trực tiếp lên đĩa
|
||||
* `shutdown`, `reboot`, `poweroff` — Tắt/khởi động lại hệ thống
|
||||
* Fork bomb `:(){ :|:& };:`
|
||||
|
||||
#### Ví dụ lỗi
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (path outside working dir)}
|
||||
```
|
||||
|
||||
```
|
||||
[ERROR] tool: Tool execution failed
|
||||
{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
|
||||
```
|
||||
|
||||
#### Tắt giới hạn (Rủi ro bảo mật)
|
||||
|
||||
Nếu bạn cần agent truy cập đường dẫn ngoài workspace:
|
||||
|
||||
**Cách 1: File cấu hình**
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"restrict_to_workspace": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Cách 2: Biến môi trường**
|
||||
|
||||
```bash
|
||||
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
||||
```
|
||||
|
||||
> ⚠️ **Cảnh báo**: Tắt giới hạn này cho phép agent truy cập mọi đường dẫn trên hệ thống. Chỉ sử dụng cẩn thận trong môi trường được kiểm soát.
|
||||
|
||||
#### Tính nhất quán của ranh giới bảo mật
|
||||
|
||||
Cài đặt `restrict_to_workspace` áp dụng nhất quán trên mọi đường thực thi:
|
||||
|
||||
| Đường thực thi | Ranh giới bảo mật |
|
||||
|----------------|-------------------|
|
||||
| Agent chính | `restrict_to_workspace` ✅ |
|
||||
| Subagent / Spawn | Kế thừa cùng giới hạn ✅ |
|
||||
| Tác vụ Heartbeat | Kế thừa cùng giới hạn ✅ |
|
||||
|
||||
Tất cả đường thực thi chia sẻ cùng giới hạn workspace — không có cách nào vượt qua ranh giới bảo mật thông qua subagent hoặc tác vụ định kỳ.
|
||||
|
||||
### Heartbeat (Tác vụ định kỳ)
|
||||
|
||||
PicoClaw có thể tự động thực hiện các tác vụ định kỳ. Tạo file `HEARTBEAT.md` trong workspace:
|
||||
|
||||
```markdown
|
||||
# Tác vụ định kỳ
|
||||
|
||||
- Kiểm tra email xem có tin nhắn quan trọng không
|
||||
- Xem lại lịch cho các sự kiện sắp tới
|
||||
- Kiểm tra dự báo thời tiết
|
||||
```
|
||||
|
||||
Agent sẽ đọc file này mỗi 30 phút (có thể cấu hình) và thực hiện các tác vụ bằng công cụ có sẵn.
|
||||
|
||||
#### Tác vụ bất đồng bộ với Spawn
|
||||
|
||||
Đối với các tác vụ chạy lâu (tìm kiếm web, gọi API), sử dụng công cụ `spawn` để tạo **subagent**:
|
||||
|
||||
```markdown
|
||||
# Tác vụ định kỳ
|
||||
|
||||
## Tác vụ nhanh (trả lời trực tiếp)
|
||||
- Báo cáo thời gian hiện tại
|
||||
|
||||
## Tác vụ lâu (dùng spawn cho async)
|
||||
- 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 |
|
||||
| **Context độc lập** | Subagent có context 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 công cụ message |
|
||||
| **Không chặn** | Sau khi spawn, heartbeat tiếp tục tác vụ tiếp theo |
|
||||
|
||||
#### Cách Subagent giao tiếp
|
||||
|
||||
```
|
||||
Heartbeat kích hoạt
|
||||
↓
|
||||
Agent đọc HEARTBEAT.md
|
||||
↓
|
||||
Tác vụ lâu: spawn subagent
|
||||
↓ ↓
|
||||
Tiếp tục tác vụ tiếp theo Subagent làm việc độc lập
|
||||
↓ ↓
|
||||
Tất cả tác vụ hoàn thành Subagent dùng công cụ "message"
|
||||
↓ ↓
|
||||
Phản hồi HEARTBEAT_OK Người dùng nhận kết quả trực tiếp
|
||||
```
|
||||
|
||||
Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và có thể giao tiếp với người dùng một cách độc lập mà không cần thông qua agent chính.
|
||||
|
||||
**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 (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
|
||||
|
||||
### Nhà cung cấp (Providers)
|
||||
|
||||
> [!NOTE]
|
||||
> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn âm thanh từ bất kỳ kênh nào sẽ được tự động chuyển thành văn bản ở cấp độ agent.
|
||||
|
||||
| Nhà cung cấp | 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](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` (Đang thử nghiệm) | LLM (khuyên dùng, truy cập mọi model) | [openrouter.ai](https://openrouter.ai) |
|
||||
| `anthropic` (Đang thử nghiệm) | LLM (Claude trực tiếp) | [console.anthropic.com](https://console.anthropic.com) |
|
||||
| `openai` (Đang thử nghiệm) | LLM (GPT trực tiếp) | [platform.openai.com](https://platform.openai.com) |
|
||||
| `deepseek` (Đang thử nghiệm) | LLM (DeepSeek trực tiếp) | [platform.deepseek.com](https://platform.deepseek.com) |
|
||||
| `groq` | LLM + **Chuyển giọng nói** (Whisper) | [console.groq.com](https://console.groq.com) |
|
||||
| `qwen` | LLM (Qwen trực tiếp) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `cerebras` | LLM (Cerebras trực tiếp) | [cerebras.ai](https://cerebras.ai) |
|
||||
|
||||
<details>
|
||||
<summary><b>Cấu hình Zhipu</b></summary>
|
||||
|
||||
**1. Lấy API key**
|
||||
|
||||
* Lấy [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||
|
||||
**2. Cấu hình**
|
||||
|
||||
```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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. Chạy**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "Xin chào"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Ví dụ cấu hình đầy đủ</b></summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "anthropic/claude-opus-4-5"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"openrouter": {
|
||||
"api_key": "sk-or-v1-xxx"
|
||||
},
|
||||
"groq": {
|
||||
"api_key": "gsk_xxx"
|
||||
}
|
||||
},
|
||||
"channels": {
|
||||
"telegram": {
|
||||
"enabled": true,
|
||||
"token": "123456:ABC...",
|
||||
"allow_from": ["123456789"]
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "",
|
||||
"allow_from": [""]
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": false
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
},
|
||||
"qq": {
|
||||
"enabled": false,
|
||||
"app_id": "",
|
||||
"app_secret": "",
|
||||
"allow_from": []
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "BSA...",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Cấu hình Mô hình (model_list)
|
||||
|
||||
> **Tính năng mới!** PicoClaw hiện sử dụng phương pháp cấu hình **đặt mô hình vào trung tâm**. Chỉ cần chỉ định dạng `nhà cung cấp/mô hình` (ví dụ: `zhipu/glm-4.7`) để thêm nhà cung cấp mới—**không cần thay đổi mã!**
|
||||
|
||||
Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa chọn nhà cung cấp linh hoạt:
|
||||
|
||||
- **Tác nhân khác nhau, nhà cung cấp khác nhau** : Mỗi tác nhân có thể sử dụng nhà cung cấp LLM riêng
|
||||
- **Mô hình dự phòng** : Cấu hình mô hình chính và dự phòng để tăng độ tin cậy
|
||||
- **Cân bằng tải** : Phân phối yêu cầu trên nhiều endpoint khác nhau
|
||||
- **Cấu hình tập trung** : Quản lý tất cả nhà cung cấp ở một nơi
|
||||
|
||||
#### 📋 Tất cả Nhà cung cấp được Hỗ trợ
|
||||
|
||||
| Nhà cung cấp | Prefix `model` | API Base Mặc định | Giao thức | Khóa API |
|
||||
|-------------|----------------|-------------------|-----------|----------|
|
||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Lấy Khóa](https://platform.openai.com) |
|
||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Lấy Khóa](https://console.anthropic.com) |
|
||||
| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Lấy Khóa](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Lấy Khóa](https://platform.deepseek.com) |
|
||||
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Lấy Khóa](https://aistudio.google.com/api-keys) |
|
||||
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Lấy Khóa](https://console.groq.com) |
|
||||
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Lấy Khóa](https://platform.moonshot.cn) |
|
||||
| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Lấy Khóa](https://dashscope.console.aliyun.com) |
|
||||
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Lấy Khóa](https://build.nvidia.com) |
|
||||
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (không cần khóa) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy Khóa](https://openrouter.ai/keys) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) |
|
||||
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
|
||||
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Lấy Khóa](https://www.byteplus.com) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Lấy Key](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Lấy Token](https://modelscope.cn/my/tokens) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth |
|
||||
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
|
||||
|
||||
#### Cấu hình Cơ bản
|
||||
|
||||
```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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Ví dụ theo Nhà cung cấp
|
||||
|
||||
**OpenAI**
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**VolcEngine (Doubao)**
|
||||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**Zhipu AI (GLM)**
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
```
|
||||
|
||||
**Anthropic (với OAuth)**
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"auth_method": "oauth"
|
||||
}
|
||||
```
|
||||
> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth.
|
||||
|
||||
**Proxy/API tùy chỉnh**
|
||||
```json
|
||||
{
|
||||
"model_name": "my-custom-model",
|
||||
"model": "openai/custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_key": "sk-...",
|
||||
"request_timeout": 300
|
||||
}
|
||||
```
|
||||
|
||||
#### Cân bằng Tải tải
|
||||
|
||||
Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng:
|
||||
|
||||
```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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Chuyển đổi từ Cấu hình `providers` Cũ
|
||||
|
||||
Cấu hình `providers` cũ đã **ngừng sử dụng** nhưng vẫn được hỗ trợ để tương thích ngược.
|
||||
|
||||
**Cấu hình Cũ (đã ngừng sử dụng):**
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"zhipu": {
|
||||
"api_key": "your-key",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Cấu hình Mới (khuyến nghị):**
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "glm-4.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Xem hướng dẫn chuyển đổi chi tiết tại [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
|
||||
|
||||
## 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 (cho bot chat) |
|
||||
| `picoclaw status` | Hiển thị trạng thái |
|
||||
| `picoclaw cron list` | Liệt kê tất cả tác vụ định kỳ |
|
||||
| `picoclaw cron add ...` | Thêm tác vụ định kỳ |
|
||||
>>>>>>> refactor/agent
|
||||
|
||||
### Tác vụ định kỳ / Nhắc nhở
|
||||
|
||||
|
||||
+532
@@ -209,6 +209,7 @@ make install
|
||||
|
||||
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> 加入 Agent 社交网络
|
||||
|
||||
<<<<<<< HEAD
|
||||
通过 CLI 或任何已集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||
|
||||
**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)**
|
||||
@@ -234,6 +235,537 @@ make install
|
||||
| `picoclaw model` | 查看或切换默认模型 |
|
||||
|
||||
### 定时任务 / 提醒
|
||||
=======
|
||||
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
|
||||
|
||||
\*\*阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)
|
||||
|
||||
## ⚙️ 配置详解
|
||||
|
||||
配置文件路径: `~/.picoclaw/config.json`
|
||||
|
||||
### 环境变量
|
||||
|
||||
你可以使用环境变量覆盖默认路径。这对于便携安装、容器化部署或将 picoclaw 作为系统服务运行非常有用。这些变量是独立的,控制不同的路径。
|
||||
|
||||
| 变量 | 描述 | 默认路径 |
|
||||
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
|
||||
| `PICOCLAW_CONFIG` | 覆盖配置文件的路径。这直接告诉 picoclaw 加载哪个 `config.json`,忽略所有其他位置。 | `~/.picoclaw/config.json` |
|
||||
| `PICOCLAW_HOME` | 覆盖 picoclaw 数据根目录。这会更改 `workspace` 和其他数据目录的默认位置。 | `~/.picoclaw` |
|
||||
|
||||
**示例:**
|
||||
|
||||
```bash
|
||||
# 使用特定的配置文件运行 picoclaw
|
||||
# 工作区路径将从该配置文件中读取
|
||||
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
|
||||
|
||||
# 在 /opt/picoclaw 中存储所有数据运行 picoclaw
|
||||
# 配置将从默认的 ~/.picoclaw/config.json 加载
|
||||
# 工作区将在 /opt/picoclaw/workspace 创建
|
||||
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
|
||||
|
||||
# 同时使用两者进行完全自定义设置
|
||||
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
|
||||
```
|
||||
|
||||
### 工作区布局 (Workspace Layout)
|
||||
|
||||
PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`):
|
||||
|
||||
```
|
||||
~/.picoclaw/workspace/
|
||||
├── sessions/ # 对话会话和历史
|
||||
├── memory/ # 长期记忆 (MEMORY.md)
|
||||
├── state/ # 持久化状态 (最后一次频道等)
|
||||
├── cron/ # 定时任务数据库
|
||||
├── skills/ # 工作区级技能
|
||||
├── AGENT.md # 结构化 Agent 定义与系统提示词
|
||||
├── SOUL.md # Agent 灵魂/性格
|
||||
├── USER.md # 当前工作区的用户资料与偏好
|
||||
├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
|
||||
└── ...
|
||||
|
||||
```
|
||||
|
||||
### 技能来源 (Skill Sources)
|
||||
|
||||
默认情况下,技能会按以下顺序加载:
|
||||
|
||||
1. `~/.picoclaw/workspace/skills`(工作区)
|
||||
2. `~/.picoclaw/skills`(全局)
|
||||
3. `<current-working-directory>/skills`(内置)
|
||||
|
||||
在高级/测试场景下,可通过以下环境变量覆盖内置技能目录:
|
||||
|
||||
```bash
|
||||
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
|
||||
```
|
||||
|
||||
### 统一命令执行策略
|
||||
|
||||
- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。
|
||||
- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。
|
||||
- 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。
|
||||
- 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。
|
||||
### 心跳 / 周期性任务 (Heartbeat)
|
||||
|
||||
PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
|
||||
|
||||
```markdown
|
||||
# Periodic Tasks
|
||||
|
||||
- Check my email for important messages
|
||||
- Review my calendar for upcoming events
|
||||
- Check the weather forecast
|
||||
```
|
||||
|
||||
Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。
|
||||
|
||||
#### 使用 Spawn 的异步任务
|
||||
|
||||
对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
**关键行为:**
|
||||
|
||||
| 特性 | 描述 |
|
||||
| ---------------- | ---------------------------------------- |
|
||||
| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 |
|
||||
| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
|
||||
| **message tool** | 子 Agent 通过 message 工具直接与用户通信 |
|
||||
| **非阻塞** | spawn 后,心跳继续处理下一个任务 |
|
||||
|
||||
#### 子 Agent 通信原理
|
||||
|
||||
```
|
||||
心跳触发 (Heartbeat triggers)
|
||||
↓
|
||||
Agent 读取 HEARTBEAT.md
|
||||
↓
|
||||
对于长任务: spawn 子 Agent
|
||||
↓ ↓
|
||||
继续下一个任务 子 Agent 独立工作
|
||||
↓ ↓
|
||||
所有任务完成 子 Agent 使用 "message" 工具
|
||||
↓ ↓
|
||||
响应 HEARTBEAT_OK 用户直接收到结果
|
||||
|
||||
```
|
||||
|
||||
子 Agent 可以访问工具(message, web_search 等),并且无需通过主 Agent 即可独立与用户通信。
|
||||
|
||||
**配置:**
|
||||
|
||||
```json
|
||||
{
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 选项 | 默认值 | 描述 |
|
||||
| ---------- | ------ | ---------------------------- |
|
||||
| `enabled` | `true` | 启用/禁用心跳 |
|
||||
| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
|
||||
|
||||
**环境变量:**
|
||||
|
||||
- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
|
||||
- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
|
||||
|
||||
### 提供商 (Providers)
|
||||
|
||||
> [!NOTE]
|
||||
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,任意渠道的音频消息都将在 Agent 层面自动转录为文字。
|
||||
|
||||
| 提供商 | 用途 | 获取 API Key |
|
||||
| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
|
||||
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
|
||||
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](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) |
|
||||
|
||||
### 模型配置 (model_list)
|
||||
|
||||
> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!**
|
||||
|
||||
该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择:
|
||||
|
||||
- **不同 Agent 使用不同 Provider**:每个 Agent 可以使用自己的 LLM provider
|
||||
- **模型回退(Fallback)**:配置主模型和备用模型,提高可靠性
|
||||
- **负载均衡**:在多个 API 端点之间分配请求
|
||||
- **集中化配置**:在一个地方管理所有 provider
|
||||
|
||||
#### 📋 所有支持的厂商
|
||||
|
||||
| 厂商 | `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 | 本地(无需密钥) |
|
||||
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
|
||||
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
|
||||
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
|
||||
| **火山引擎(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) |
|
||||
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
|
||||
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://www.byteplus.com) |
|
||||
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) |
|
||||
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取 Token](https://modelscope.cn/my/tokens) |
|
||||
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 各厂商配置示例
|
||||
|
||||
**OpenAI**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "gpt-5.4",
|
||||
"model": "openai/gpt-5.4",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**火山引擎(Doubao)**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "ark-code-latest",
|
||||
"model": "volcengine/ark-code-latest",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**智谱 AI (GLM)**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
```
|
||||
|
||||
**DeepSeek**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "deepseek-chat",
|
||||
"model": "deepseek/deepseek-chat",
|
||||
"api_key": "sk-..."
|
||||
}
|
||||
```
|
||||
|
||||
**Anthropic (使用 OAuth)**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "claude-sonnet-4.6",
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
"auth_method": "oauth"
|
||||
}
|
||||
```
|
||||
|
||||
> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
|
||||
|
||||
**Anthropic Messages API(原生格式)**
|
||||
|
||||
用于直接访问 Anthropic API 或仅支持 Anthropic 原生消息格式的自定义端点:
|
||||
|
||||
```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"
|
||||
}
|
||||
```
|
||||
|
||||
> 使用 `anthropic-messages` 协议的场景:
|
||||
> - 使用仅支持 Anthropic 原生 `/v1/messages` 端点的第三方代理(不支持 OpenAI 兼容的 `/v1/chat/completions`)
|
||||
> - 连接到 MiniMax、Synthetic 等需要 Anthropic 原生消息格式的服务
|
||||
> - 现有的 `anthropic` 协议返回 404 错误(说明端点不支持 OpenAI 兼容格式)
|
||||
>
|
||||
> **注意:** `anthropic` 协议使用 OpenAI 兼容格式(`/v1/chat/completions`),而 `anthropic-messages` 使用 Anthropic 原生格式(`/v1/messages`)。请根据端点支持的格式选择。
|
||||
|
||||
**Ollama (本地)**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "llama3",
|
||||
"model": "ollama/llama3"
|
||||
}
|
||||
```
|
||||
|
||||
**自定义代理/API**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_name": "my-custom-model",
|
||||
"model": "openai/custom-model",
|
||||
"api_base": "https://my-proxy.com/v1",
|
||||
"api_key": "sk-...",
|
||||
"request_timeout": 300
|
||||
}
|
||||
```
|
||||
|
||||
#### 负载均衡
|
||||
|
||||
为同一个模型名称配置多个端点——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` 配置格式**已弃用**,但为向后兼容仍支持。
|
||||
|
||||
**旧配置(已弃用):**
|
||||
|
||||
```json
|
||||
{
|
||||
"providers": {
|
||||
"zhipu": {
|
||||
"api_key": "your-key",
|
||||
"api_base": "https://open.bigmodel.cn/api/paas/v4"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"provider": "zhipu",
|
||||
"model": "glm-4.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**新配置(推荐):**
|
||||
|
||||
```json
|
||||
{
|
||||
"model_list": [
|
||||
{
|
||||
"model_name": "glm-4.7",
|
||||
"model": "zhipu/glm-4.7",
|
||||
"api_key": "your-key"
|
||||
}
|
||||
],
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"model": "glm-4.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
详细的迁移指南请参考 [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md)。
|
||||
|
||||
<details>
|
||||
<summary><b>智谱 (Zhipu) 配置示例</b></summary>
|
||||
|
||||
**1. 获取 API key 和 base URL**
|
||||
|
||||
- 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
|
||||
|
||||
**2. 配置**
|
||||
|
||||
```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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. 运行**
|
||||
|
||||
```bash
|
||||
picoclaw agent -m "你好"
|
||||
|
||||
```
|
||||
|
||||
</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"]
|
||||
},
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"token": "",
|
||||
"allow_from": [""]
|
||||
},
|
||||
"whatsapp": {
|
||||
"enabled": false
|
||||
},
|
||||
"feishu": {
|
||||
"enabled": false,
|
||||
"app_id": "cli_xxx",
|
||||
"app_secret": "xxx",
|
||||
"encrypt_key": "",
|
||||
"verification_token": "",
|
||||
"allow_from": []
|
||||
},
|
||||
"qq": {
|
||||
"enabled": false,
|
||||
"app_id": "",
|
||||
"app_secret": "",
|
||||
"allow_from": []
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"web": {
|
||||
"brave": {
|
||||
"enabled": false,
|
||||
"api_key": "YOUR_BRAVE_API_KEY",
|
||||
"max_results": 5
|
||||
},
|
||||
"duckduckgo": {
|
||||
"enabled": true,
|
||||
"max_results": 5
|
||||
}
|
||||
},
|
||||
"cron": {
|
||||
"exec_timeout_minutes": 5
|
||||
}
|
||||
},
|
||||
"heartbeat": {
|
||||
"enabled": true,
|
||||
"interval": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## CLI 命令行参考
|
||||
|
||||
| 命令 | 描述 |
|
||||
| ------------------------- | ------------------ |
|
||||
| `picoclaw onboard` | 初始化配置和工作区 |
|
||||
| `picoclaw agent -m "..."` | 与 Agent 对话 |
|
||||
| `picoclaw agent` | 交互式聊天模式 |
|
||||
| `picoclaw gateway` | 启动网关 (Gateway) |
|
||||
| `picoclaw status` | 显示状态 |
|
||||
| `picoclaw cron list` | 列出所有定时任务 |
|
||||
| `picoclaw cron add ...` | 添加定时任务 |
|
||||
|
||||
### 定时任务 / 提醒 (Scheduled Tasks)
|
||||
>>>>>>> refactor/agent
|
||||
|
||||
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"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,
|
||||
@@ -549,6 +550,14 @@
|
||||
"voice": {
|
||||
"echo_transcription": false
|
||||
},
|
||||
"hooks": {
|
||||
"enabled": true,
|
||||
"defaults": {
|
||||
"observer_timeout_ms": 500,
|
||||
"interceptor_timeout_ms": 5000,
|
||||
"approval_timeout_ms": 60000
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 18790,
|
||||
|
||||
@@ -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,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,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`。
|
||||
+34
-1
@@ -21,6 +21,18 @@ Agent Loop ▼
|
||||
└─ 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`:
|
||||
@@ -86,12 +98,18 @@ if response == "" {
|
||||
|
||||
`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 **two points** in the agent cycle:
|
||||
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
|
||||
|
||||
@@ -156,11 +174,26 @@ When the agent loop (`Run()`) starts processing a message, it spawns a backgroun
|
||||
|
||||
- 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.
|
||||
|
||||
+8
-9
@@ -25,7 +25,8 @@ When spawning a SubTurn, you must provide a `SubTurnConfig`:
|
||||
| :--- | :--- | :--- |
|
||||
| `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 system instruction for the sub-task. |
|
||||
| `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. |
|
||||
@@ -134,14 +135,12 @@ All active root turns are registered in `AgentLoop.activeTurnStates` (`sync.Map`
|
||||
|
||||
SubTurns emit specific events to the PicoClaw `EventBus` for observability and debugging:
|
||||
|
||||
| Event | When Emitted | Payload |
|
||||
| Event Kind | When Emitted | Payload |
|
||||
|:------|:-------------|:--------|
|
||||
| `SubTurnSpawnEvent` | Sub-turn successfully initialized | `ParentID`, `ChildID`, `Config` |
|
||||
| `SubTurnEndEvent` | Sub-turn finishes (success or error) | `ChildID`, `Result`, `Err` |
|
||||
| `SubTurnResultDeliveredEvent` | Async result successfully delivered to parent | `ParentID`, `ChildID`, `Result` |
|
||||
| `SubTurnOrphanResultEvent` | Result cannot be delivered (parent finished or channel full) | `ParentID`, `ChildID`, `Result` |
|
||||
|
||||
> **⚠️ POC Note:** The current `EventBus` implementation is `MockEventBus`, a placeholder that only prints events to stdout via `fmt.Printf`. It is not a production-grade event system. Do not rely on it for programmatic event consumption; a real EventBus integration is planned.
|
||||
| `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
|
||||
|
||||
@@ -200,8 +199,8 @@ SubTurn relies on context values for proper operation:
|
||||
|
||||
```go
|
||||
// Before calling tools that may spawn SubTurns
|
||||
ctx = withTurnState(ctx, turnState)
|
||||
ctx = WithAgentLoop(ctx, agentLoop)
|
||||
ctx = withTurnState(ctx, turnState)
|
||||
```
|
||||
|
||||
### Independent Child Context
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
# Agent Loop 流程图对比
|
||||
|
||||
## 1. Incoming (refactor/agent) 流程
|
||||
|
||||
### 整体架构
|
||||
```
|
||||
User Message
|
||||
↓
|
||||
Message Bus (串行队列)
|
||||
↓
|
||||
processMessage()
|
||||
↓
|
||||
runAgentLoop()
|
||||
↓
|
||||
newTurnState() → 创建 turnState
|
||||
↓
|
||||
runTurn()
|
||||
↓
|
||||
registerActiveTurn(ts) ← 设置 al.activeTurn = ts (单例)
|
||||
↓
|
||||
[Turn 执行循环]
|
||||
↓
|
||||
clearActiveTurn(ts) ← 清除 al.activeTurn = nil
|
||||
```
|
||||
|
||||
### runTurn() 详细流程
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ runTurn(ctx, turnState) │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 1. 注册 activeTurn (单例) │
|
||||
│ al.registerActiveTurn(ts) │
|
||||
│ defer al.clearActiveTurn(ts) │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 2. 发送 TurnStart 事件 │
|
||||
│ al.emitEvent(EventKindTurnStart) │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 3. 加载 Session History & Summary │
|
||||
│ history = Sessions.GetHistory() │
|
||||
│ summary = Sessions.GetSummary() │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 4. 构建消息 │
|
||||
│ messages = BuildMessages(...) │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 5. 检查 Context Budget │
|
||||
│ if isOverContextBudget() { │
|
||||
│ forceCompression() │
|
||||
│ emitEvent(ContextCompress) │
|
||||
│ } │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 6. 保存用户消息到 Session │
|
||||
│ Sessions.AddMessage("user", ...) │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 7. Turn Loop (迭代执行) │
|
||||
│ for iteration < MaxIterations { │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ 7.1 调用 LLM │ │
|
||||
│ │ callLLM() │ │
|
||||
│ │ emitEvent(LLMStart) │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ 7.2 处理 Tool Calls │ │
|
||||
│ │ for each toolCall { │ │
|
||||
│ │ emitEvent(ToolStart)│ │
|
||||
│ │ executeTool() │ │
|
||||
│ │ emitEvent(ToolEnd) │ │
|
||||
│ │ } │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ 7.3 检查中断 │ │
|
||||
│ │ if gracefulInterrupt { │ │
|
||||
│ │ break │ │
|
||||
│ │ } │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ 7.4 处理 Steering Messages │ │
|
||||
│ │ pollSteering() │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ } │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 8. 保存最终响应到 Session │
|
||||
│ Sessions.AddMessage("assistant", ...) │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 9. 发送 TurnEnd 事件 │
|
||||
│ al.emitEvent(EventKindTurnEnd) │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 10. 返回 turnResult │
|
||||
│ {finalContent, status, followUps} │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 关键特点
|
||||
- ✅ **事件驱动**: 每个阶段都发送事件到 EventBus
|
||||
- ✅ **Hook 集成**: 在 before_llm, after_llm, before_tool, after_tool 触发 Hook
|
||||
- ✅ **单 Turn**: 使用 `activeTurn` 单例,同一时间只有一个 turn
|
||||
- ❌ **无并发**: 不支持多个 session 同时执行 turn
|
||||
|
||||
---
|
||||
|
||||
## 2. HEAD (feat/subturn-poc) 流程
|
||||
|
||||
### 整体架构
|
||||
```
|
||||
User Message
|
||||
↓
|
||||
Message Bus
|
||||
↓
|
||||
processMessage()
|
||||
↓
|
||||
runAgentLoop()
|
||||
↓
|
||||
检查 Context 中是否有 turnState
|
||||
├─ 有 → 复用 (SubTurn 场景)
|
||||
└─ 无 → 创建新的 rootTS
|
||||
↓
|
||||
存储到 activeTurnStates[sessionKey]
|
||||
↓
|
||||
runLLMIteration()
|
||||
↓
|
||||
[并发 SubTurn 支持]
|
||||
```
|
||||
|
||||
### runAgentLoop() 详细流程
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ runAgentLoop(ctx, agent, opts) │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 1. 检查是否在 SubTurn 中 │
|
||||
│ existingTS = turnStateFromContext() │
|
||||
│ if existingTS != nil { │
|
||||
│ rootTS = existingTS (复用) │
|
||||
│ isRootTurn = false │
|
||||
│ } else { │
|
||||
│ rootTS = new turnState │
|
||||
│ isRootTurn = true │
|
||||
│ } │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 2. 注册 Turn State (支持并发) │
|
||||
│ if isRootTurn { │
|
||||
│ al.activeTurnStates.Store( │
|
||||
│ sessionKey, rootTS) │
|
||||
│ defer activeTurnStates.Delete() │
|
||||
│ } │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 3. 记录 Last Channel │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 4. 构建消息 │
|
||||
│ messages = BuildMessages(...) │
|
||||
│ messages = resolveMediaRefs(...) │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 5. 覆盖 System Prompt (如果需要) │
|
||||
│ if opts.SystemPromptOverride != "" { │
|
||||
│ // 用于 SubTurn 的特殊 prompt │
|
||||
│ } │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 6. 保存用户消息 │
|
||||
│ if !opts.SkipAddUserMessage { │
|
||||
│ Sessions.AddMessage(...) │
|
||||
│ } │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 7. 执行 LLM 迭代 │
|
||||
│ finalContent, iteration, err = │
|
||||
│ runLLMIteration(ctx, agent, ...) │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 8. 轮询 SubTurn 结果 (如果是根 turn) │
|
||||
│ if isRootTurn { │
|
||||
│ results = │
|
||||
│ dequeuePendingSubTurnResults()│
|
||||
│ // 将结果注入到最终响应 │
|
||||
│ } │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 9. 处理空响应 │
|
||||
│ if finalContent == "" { │
|
||||
│ finalContent = DefaultResponse │
|
||||
│ } │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 10. 保存助手响应 │
|
||||
│ Sessions.AddMessage("assistant"...) │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 11. 发送响应 (如果需要) │
|
||||
│ if opts.SendResponse { │
|
||||
│ bus.PublishOutbound(...) │
|
||||
│ } │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### SubTurn 执行流程
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Tool: spawn │
|
||||
│ args: {task: "...", label: "..."} │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ SpawnTool.Execute() │
|
||||
│ if spawner != nil { │
|
||||
│ // 直接 SubTurn 路径 │
|
||||
│ } else { │
|
||||
│ // SubagentManager 路径 │
|
||||
│ } │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ spawner.SpawnSubTurn() │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 1. 生成 SubTurn ID │ │
|
||||
│ │ subTurnID = atomic.Add() │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 2. 创建 SubTurn Context │ │
|
||||
│ │ subCtx = withTurnState(...) │ │
|
||||
│ │ // 继承父 turnState │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 3. 获取并发信号量 │ │
|
||||
│ │ <-rootTS.concurrencySem │ │
|
||||
│ │ defer release │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ ↓ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 4. 启动 Goroutine │ │
|
||||
│ │ go func() { │ │
|
||||
│ │ result = runAgentLoop( │ │
|
||||
│ │ subCtx, ...) │ │
|
||||
│ │ // 将结果发送到 channel │ │
|
||||
│ │ rootTS.pendingResults <- │ │
|
||||
│ │ }() │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 父 Turn 继续执行 │
|
||||
│ - 不等待 SubTurn 完成 │
|
||||
│ - SubTurn 异步执行 │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 父 Turn 轮询 SubTurn 结果 │
|
||||
│ results = dequeuePendingSubTurnResults│
|
||||
│ for each result { │
|
||||
│ // 注入到响应或下一次迭代 │
|
||||
│ } │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### SubTurn 层级结构
|
||||
```
|
||||
Root Turn (Session A)
|
||||
├─ turnState (depth=0)
|
||||
│ ├─ turnID: "session-a"
|
||||
│ ├─ pendingResults: chan
|
||||
│ └─ concurrencySem: chan (限制并发数)
|
||||
│
|
||||
├─ SubTurn 1 (depth=1)
|
||||
│ ├─ turnState (继承父 context)
|
||||
│ ├─ parentTurnID: "session-a"
|
||||
│ └─ 独立的 goroutine
|
||||
│
|
||||
├─ SubTurn 2 (depth=1)
|
||||
│ ├─ turnState (继承父 context)
|
||||
│ ├─ parentTurnID: "session-a"
|
||||
│ └─ 独立的 goroutine
|
||||
│
|
||||
└─ SubTurn 3 (depth=1)
|
||||
└─ SubTurn 3.1 (depth=2) ← 嵌套 SubTurn
|
||||
└─ ...
|
||||
|
||||
Root Turn (Session B) - 并发执行
|
||||
├─ turnState (depth=0)
|
||||
└─ ...
|
||||
```
|
||||
|
||||
### 关键特点
|
||||
- ✅ **并发支持**: `activeTurnStates` map 支持多个 session 并发
|
||||
- ✅ **SubTurn 层级**: 通过 context 传递 turnState,支持嵌套
|
||||
- ✅ **并发控制**: `concurrencySem` 限制 SubTurn 并发数
|
||||
- ✅ **异步执行**: SubTurn 在独立 goroutine 中执行
|
||||
- ✅ **结果回传**: 通过 `pendingResults` channel 传递结果
|
||||
- ❌ **无事件系统**: 没有 EventBus 和 Hook 集成
|
||||
|
||||
---
|
||||
|
||||
## 3. 对比总结
|
||||
|
||||
| 特性 | Incoming (refactor/agent) | HEAD (feat/subturn-poc) |
|
||||
|------|---------------------------|-------------------------|
|
||||
| **并发模型** | 单 Turn (串行) | 多 Turn (并发) |
|
||||
| **Turn 管理** | `activeTurn` (单例) | `activeTurnStates` (map) |
|
||||
| **事件系统** | ✅ EventBus | ❌ 无 |
|
||||
| **Hook 系统** | ✅ HookManager | ❌ 无 |
|
||||
| **SubTurn** | ❓ 未实现或不同方式 | ✅ 完整实现 |
|
||||
| **并发 Session** | ❌ 不支持 | ✅ 支持 |
|
||||
| **嵌套 SubTurn** | ❌ 不支持 | ✅ 支持 |
|
||||
| **架构复杂度** | 简单 | 复杂 |
|
||||
| **可扩展性** | 高 (Hook) | 低 |
|
||||
| **调试难度** | 低 | 高 (并发) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 混合方案流程
|
||||
|
||||
结合两者优点的混合方案:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ runAgentLoop(ctx, agent, opts) │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 1. 检查 SubTurn Context │
|
||||
│ existingTS = turnStateFromContext() │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 2. 创建/复用 turnState │
|
||||
│ ts = newTurnState(agent, opts, ...) │
|
||||
│ if isRootTurn { │
|
||||
│ activeTurnStates.Store(key, ts) │
|
||||
│ } │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 3. 执行 Turn (带事件和 Hook) │
|
||||
│ result = runTurn(ctx, ts) │
|
||||
│ ├─ emitEvent(TurnStart) │
|
||||
│ ├─ Hook: before_llm │
|
||||
│ ├─ callLLM() │
|
||||
│ ├─ Hook: after_llm │
|
||||
│ ├─ Hook: before_tool │
|
||||
│ ├─ executeTool() │
|
||||
│ │ └─ 如果是 spawn → SpawnSubTurn │
|
||||
│ ├─ Hook: after_tool │
|
||||
│ └─ emitEvent(TurnEnd) │
|
||||
└─────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 4. 处理 SubTurn 结果 │
|
||||
│ if isRootTurn { │
|
||||
│ pollSubTurnResults() │
|
||||
│ } │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 混合方案优势
|
||||
- ✅ 保留并发能力 (`activeTurnStates`)
|
||||
- ✅ 获得事件系统 (`EventBus`)
|
||||
- ✅ 获得扩展能力 (`HookManager`)
|
||||
- ✅ 支持 SubTurn 并发
|
||||
- ✅ 支持多 Session 并发
|
||||
@@ -0,0 +1,563 @@
|
||||
# 混合方案落地指南
|
||||
|
||||
## 目标
|
||||
|
||||
结合 Incoming 的事件驱动架构和 HEAD 的并发能力,实现:
|
||||
- ✅ 保留 `activeTurnStates` map(支持并发 Session)
|
||||
- ✅ 采用 `EventBus` 和 `HookManager`(事件驱动 + 扩展性)
|
||||
- ✅ 保留 SubTurn 并发支持
|
||||
- ✅ 统一使用 `runTurn` 函数(简化代码)
|
||||
|
||||
---
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### 步骤 1: 合并 AgentLoop 结构体 (30 分钟)
|
||||
|
||||
**目标**: 结合两边的字段
|
||||
|
||||
```go
|
||||
type AgentLoop struct {
|
||||
// ===== Incoming 的字段 (保留) =====
|
||||
bus *bus.MessageBus
|
||||
cfg *config.Config
|
||||
registry *AgentRegistry
|
||||
state *state.Manager
|
||||
eventBus *EventBus // ✅ 新增:事件系统
|
||||
hooks *HookManager // ✅ 新增:Hook 系统
|
||||
running atomic.Bool
|
||||
summarizing sync.Map
|
||||
fallback *providers.FallbackChain
|
||||
channelManager *channels.Manager
|
||||
mediaStore media.MediaStore
|
||||
transcriber voice.Transcriber
|
||||
cmdRegistry *commands.Registry
|
||||
mcp mcpRuntime
|
||||
hookRuntime hookRuntime // ✅ 新增:Hook 运行时
|
||||
steering *steeringQueue
|
||||
mu sync.RWMutex
|
||||
|
||||
// ===== HEAD 的字段 (保留) =====
|
||||
activeTurnStates sync.Map // ✅ 保留:支持并发 Session
|
||||
subTurnCounter atomic.Int64 // ✅ 保留:SubTurn ID 生成
|
||||
|
||||
// ===== Incoming 的字段 (调整) =====
|
||||
turnSeq atomic.Uint64 // ✅ 保留:全局 Turn 序列号
|
||||
activeRequests sync.WaitGroup // ✅ 保留:请求跟踪
|
||||
|
||||
reloadFunc func() error
|
||||
}
|
||||
```
|
||||
|
||||
**操作**:
|
||||
1. 找到 AgentLoop 结构体定义(38-77 行的冲突)
|
||||
2. 采用上面的合并版本
|
||||
3. 删除 Incoming 的 `activeTurn *turnState` 和 `activeTurnMu`(不需要了)
|
||||
|
||||
---
|
||||
|
||||
### 步骤 2: 合并 processOptions 结构体 (10 分钟)
|
||||
|
||||
**目标**: 采用 Incoming 的版本,移除 HEAD 的 `SkipAddUserMessage`
|
||||
|
||||
```go
|
||||
type processOptions struct {
|
||||
SessionKey string
|
||||
Channel string
|
||||
ChatID string
|
||||
SenderID string
|
||||
SenderDisplayName string
|
||||
UserMessage string
|
||||
SystemPromptOverride string
|
||||
Media []string
|
||||
InitialSteeringMessages []providers.Message // ✅ Incoming 的方式
|
||||
DefaultResponse string
|
||||
EnableSummary bool
|
||||
SendResponse bool
|
||||
NoHistory bool
|
||||
SkipInitialSteeringPoll bool
|
||||
}
|
||||
|
||||
type continuationTarget struct {
|
||||
SessionKey string
|
||||
Channel string
|
||||
ChatID string
|
||||
}
|
||||
```
|
||||
|
||||
**操作**:
|
||||
1. 找到 processOptions 结构体(92-112 行的冲突)
|
||||
2. 采用上面的版本
|
||||
3. 添加 `continuationTarget` 结构体
|
||||
|
||||
---
|
||||
|
||||
### 步骤 3: 更新 turnState 结构体 (20 分钟)
|
||||
|
||||
**目标**: 在 Incoming 的 turnState 基础上添加 SubTurn 支持
|
||||
|
||||
需要检查 `turn.go` 或 `turn_state.go` 文件,确保 turnState 有这些字段:
|
||||
|
||||
```go
|
||||
type turnState struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
// ===== Incoming 的字段 (保留) =====
|
||||
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
|
||||
|
||||
// ===== HEAD 的字段 (新增:SubTurn 支持) =====
|
||||
depth int // ✅ SubTurn 深度
|
||||
parentTurnID string // ✅ 父 Turn ID
|
||||
childTurnIDs []string // ✅ 子 Turn IDs
|
||||
pendingResults chan *tools.ToolResult // ✅ SubTurn 结果 channel
|
||||
concurrencySem chan struct{} // ✅ 并发信号量
|
||||
isFinished atomic.Bool // ✅ 是否已完成
|
||||
}
|
||||
```
|
||||
|
||||
**操作**:
|
||||
1. 查找 `turnState` 结构体定义
|
||||
2. 如果有冲突,采用 Incoming 的基础版本
|
||||
3. 添加 SubTurn 相关字段(depth, parentTurnID 等)
|
||||
|
||||
---
|
||||
|
||||
### 步骤 4: 重写 runAgentLoop 函数 (1 小时)
|
||||
|
||||
**目标**: 简化为调用 runTurn,但保留 SubTurn 检测
|
||||
|
||||
```go
|
||||
func (al *AgentLoop) runAgentLoop(
|
||||
ctx context.Context,
|
||||
agent *AgentInstance,
|
||||
opts processOptions,
|
||||
) (string, error) {
|
||||
// 1. 检查是否在 SubTurn 中
|
||||
existingTS := turnStateFromContext(ctx)
|
||||
var ts *turnState
|
||||
var isRootTurn bool
|
||||
|
||||
if existingTS != nil {
|
||||
// 在 SubTurn 中 - 创建子 turnState
|
||||
ts = newSubTurnState(agent, opts, existingTS, al.newTurnEventScope(agent.ID, opts.SessionKey))
|
||||
isRootTurn = false
|
||||
} else {
|
||||
// 根 Turn - 创建新的 turnState
|
||||
ts = newTurnState(agent, opts, al.newTurnEventScope(agent.ID, opts.SessionKey))
|
||||
isRootTurn = true
|
||||
|
||||
// 注册到 activeTurnStates(支持并发)
|
||||
al.activeTurnStates.Store(opts.SessionKey, ts)
|
||||
defer al.activeTurnStates.Delete(opts.SessionKey)
|
||||
}
|
||||
|
||||
// 2. 记录 last channel
|
||||
if opts.Channel != "" && opts.ChatID != "" && !constants.IsInternalChannel(opts.Channel) {
|
||||
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
|
||||
if err := al.RecordLastChannel(channelKey); err != nil {
|
||||
logger.WarnCF("agent", "Failed to record last channel",
|
||||
map[string]any{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 执行 Turn(带事件和 Hook)
|
||||
result, err := al.runTurn(ctx, ts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.status == TurnEndStatusAborted {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// 4. 处理 SubTurn 结果(仅根 Turn)
|
||||
if isRootTurn && ts.pendingResults != nil {
|
||||
finalResults := al.drainPendingSubTurnResults(ts)
|
||||
for _, r := range finalResults {
|
||||
if r != nil && r.ForLLM != "" {
|
||||
result.finalContent += fmt.Sprintf("\n\n[SubTurn Result] %s", r.ForLLM)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 处理 follow-up 消息
|
||||
for _, followUp := range result.followUps {
|
||||
if pubErr := al.bus.PublishInbound(ctx, followUp); pubErr != nil {
|
||||
logger.WarnCF("agent", "Failed to publish follow-up after turn",
|
||||
map[string]any{"turn_id": ts.turnID, "error": pubErr.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 发送响应
|
||||
if opts.SendResponse && result.finalContent != "" {
|
||||
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||
Channel: opts.Channel,
|
||||
ChatID: opts.ChatID,
|
||||
Content: result.finalContent,
|
||||
})
|
||||
}
|
||||
|
||||
return result.finalContent, nil
|
||||
}
|
||||
```
|
||||
|
||||
**操作**:
|
||||
1. 找到 runAgentLoop 函数(1439-1581 行的冲突)
|
||||
2. 替换为上面的简化版本
|
||||
3. 保留 SubTurn 检测逻辑(`turnStateFromContext`)
|
||||
4. 保留 `activeTurnStates` 注册逻辑
|
||||
|
||||
---
|
||||
|
||||
### 步骤 5: 采用 Incoming 的 runTurn 函数 (30 分钟)
|
||||
|
||||
**目标**: 使用 Incoming 的 runTurn,但添加 SubTurn 结果轮询
|
||||
|
||||
```go
|
||||
func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, error) {
|
||||
turnCtx, turnCancel := context.WithCancel(ctx)
|
||||
defer turnCancel()
|
||||
ts.setTurnCancel(turnCancel)
|
||||
|
||||
// ===== 不使用单例 activeTurn,因为我们有 activeTurnStates =====
|
||||
// al.registerActiveTurn(ts) ← 删除这行
|
||||
// defer al.clearActiveTurn(ts) ← 删除这行
|
||||
|
||||
turnStatus := TurnEndStatusCompleted
|
||||
defer func() {
|
||||
al.emitEvent(
|
||||
EventKindTurnEnd,
|
||||
ts.eventMeta("runTurn", "turn.end"),
|
||||
TurnEndPayload{
|
||||
Status: turnStatus,
|
||||
Iterations: ts.currentIteration(),
|
||||
Duration: time.Since(ts.startedAt),
|
||||
FinalContentLen: ts.finalContentLen(),
|
||||
},
|
||||
)
|
||||
}()
|
||||
|
||||
al.emitEvent(
|
||||
EventKindTurnStart,
|
||||
ts.eventMeta("runTurn", "turn.start"),
|
||||
TurnStartPayload{
|
||||
Channel: ts.channel,
|
||||
ChatID: ts.chatID,
|
||||
UserMessage: ts.userMessage,
|
||||
MediaCount: len(ts.media),
|
||||
},
|
||||
)
|
||||
|
||||
// ... 保留 Incoming 的其余逻辑 ...
|
||||
|
||||
// ===== 在 Turn Loop 中添加 SubTurn 结果轮询 =====
|
||||
turnLoop:
|
||||
for ts.currentIteration() < ts.agent.MaxIterations || len(pendingMessages) > 0 {
|
||||
// ... LLM 调用 ...
|
||||
// ... Tool 执行 ...
|
||||
|
||||
// ✅ 新增:轮询 SubTurn 结果
|
||||
if ts.pendingResults != nil {
|
||||
subTurnResults := al.pollSubTurnResults(ts)
|
||||
for _, result := range subTurnResults {
|
||||
if result.ForLLM != "" {
|
||||
// 将 SubTurn 结果作为 steering message 注入
|
||||
pendingMessages = append(pendingMessages, providers.Message{
|
||||
Role: "user",
|
||||
Content: fmt.Sprintf("[SubTurn Result] %s", result.ForLLM),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ... 继续迭代 ...
|
||||
}
|
||||
|
||||
// ... 返回结果 ...
|
||||
}
|
||||
```
|
||||
|
||||
**操作**:
|
||||
1. 找到 runTurn 函数(1672-1689 行开始的冲突)
|
||||
2. 采用 Incoming 的完整实现
|
||||
3. 删除 `registerActiveTurn` 和 `clearActiveTurn` 调用
|
||||
4. 在 Turn Loop 中添加 SubTurn 结果轮询逻辑
|
||||
|
||||
---
|
||||
|
||||
### 步骤 6: 实现辅助函数 (30 分钟)
|
||||
|
||||
需要实现以下辅助函数:
|
||||
|
||||
#### 6.1 newSubTurnState
|
||||
```go
|
||||
func newSubTurnState(
|
||||
agent *AgentInstance,
|
||||
opts processOptions,
|
||||
parent *turnState,
|
||||
scope turnEventScope,
|
||||
) *turnState {
|
||||
ts := newTurnState(agent, opts, scope)
|
||||
|
||||
// 设置 SubTurn 关系
|
||||
ts.depth = parent.depth + 1
|
||||
ts.parentTurnID = parent.turnID
|
||||
ts.pendingResults = parent.pendingResults // 共享结果 channel
|
||||
ts.concurrencySem = parent.concurrencySem // 共享信号量
|
||||
|
||||
// 记录父子关系
|
||||
parent.mu.Lock()
|
||||
parent.childTurnIDs = append(parent.childTurnIDs, ts.turnID)
|
||||
parent.mu.Unlock()
|
||||
|
||||
return ts
|
||||
}
|
||||
```
|
||||
|
||||
#### 6.2 pollSubTurnResults
|
||||
```go
|
||||
func (al *AgentLoop) pollSubTurnResults(ts *turnState) []*tools.ToolResult {
|
||||
if ts.pendingResults == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var results []*tools.ToolResult
|
||||
for {
|
||||
select {
|
||||
case result := <-ts.pendingResults:
|
||||
results = append(results, result)
|
||||
default:
|
||||
return results
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 6.3 drainPendingSubTurnResults
|
||||
```go
|
||||
func (al *AgentLoop) drainPendingSubTurnResults(ts *turnState) []*tools.ToolResult {
|
||||
if ts.pendingResults == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 等待一小段时间,确保所有 SubTurn 结果都到达
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
return al.pollSubTurnResults(ts)
|
||||
}
|
||||
```
|
||||
|
||||
#### 6.4 更新 GetActiveTurn
|
||||
```go
|
||||
func (al *AgentLoop) GetActiveTurn(sessionKey string) *ActiveTurnInfo {
|
||||
val, ok := al.activeTurnStates.Load(sessionKey)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
ts, ok := val.(*turnState)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
info := ts.snapshot()
|
||||
return &info
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 步骤 7: 更新 SpawnSubTurn 实现 (30 分钟)
|
||||
|
||||
确保 spawn tool 能正确创建 SubTurn:
|
||||
|
||||
```go
|
||||
func (spawner *subTurnSpawner) SpawnSubTurn(
|
||||
ctx context.Context,
|
||||
config SubTurnConfig,
|
||||
) (*tools.ToolResult, error) {
|
||||
// 1. 获取父 turnState
|
||||
parentTS := turnStateFromContext(ctx)
|
||||
if parentTS == nil {
|
||||
return nil, fmt.Errorf("no parent turn state in context")
|
||||
}
|
||||
|
||||
// 2. 检查深度限制
|
||||
maxDepth := spawner.loop.getSubTurnConfig().maxDepth
|
||||
if parentTS.depth >= maxDepth {
|
||||
return tools.ErrorResult(fmt.Sprintf(
|
||||
"SubTurn depth limit reached (%d)", maxDepth)), nil
|
||||
}
|
||||
|
||||
// 3. 获取并发信号量
|
||||
select {
|
||||
case <-parentTS.concurrencySem:
|
||||
defer func() { parentTS.concurrencySem <- struct{}{} }()
|
||||
case <-ctx.Done():
|
||||
return tools.ErrorResult("SubTurn cancelled"), nil
|
||||
}
|
||||
|
||||
// 4. 生成 SubTurn ID
|
||||
subTurnID := spawner.loop.subTurnCounter.Add(1)
|
||||
turnID := fmt.Sprintf("%s-sub-%d", parentTS.turnID, subTurnID)
|
||||
|
||||
// 5. 创建 SubTurn context
|
||||
subCtx := withTurnState(ctx, parentTS) // 继承父 context
|
||||
|
||||
// 6. 启动 SubTurn goroutine
|
||||
go func() {
|
||||
opts := processOptions{
|
||||
SessionKey: parentTS.sessionKey,
|
||||
Channel: parentTS.channel,
|
||||
ChatID: parentTS.chatID,
|
||||
UserMessage: config.SystemPrompt,
|
||||
SystemPromptOverride: config.SystemPrompt,
|
||||
NoHistory: true, // SubTurn 不加载历史
|
||||
SendResponse: false, // SubTurn 不发送响应
|
||||
}
|
||||
|
||||
result, err := spawner.loop.runAgentLoop(subCtx, spawner.agent, opts)
|
||||
|
||||
// 7. 发送结果到父 Turn
|
||||
toolResult := &tools.ToolResult{
|
||||
ForLLM: result,
|
||||
Error: err,
|
||||
}
|
||||
|
||||
select {
|
||||
case parentTS.pendingResults <- toolResult:
|
||||
case <-subCtx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
// 8. 立即返回(异步执行)
|
||||
return tools.AsyncResult(fmt.Sprintf("SubTurn %d started", subTurnID)), nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 步骤 8: 解决其他小冲突 (1 小时)
|
||||
|
||||
处理剩余的 7 个冲突点:
|
||||
|
||||
1. **变量命名冲突** (2179-2183 行等)
|
||||
- 统一使用 `ts.channel`, `ts.chatID` 而不是 `opts.Channel`
|
||||
|
||||
2. **Tool feedback** (2469-2494 行)
|
||||
- 采用 HEAD 的实现(发送 tool feedback 到 chat)
|
||||
|
||||
3. **其他小差异**
|
||||
- 逐个检查,优先采用 Incoming 的实现
|
||||
- 确保 EventBus 事件正确触发
|
||||
|
||||
---
|
||||
|
||||
## 验证步骤
|
||||
|
||||
### 1. 编译验证
|
||||
```bash
|
||||
go build ./pkg/agent/
|
||||
```
|
||||
|
||||
### 2. 单元测试
|
||||
```bash
|
||||
go test ./pkg/agent/ -v
|
||||
```
|
||||
|
||||
### 3. 功能测试
|
||||
|
||||
创建测试用例验证:
|
||||
|
||||
```go
|
||||
func TestMixedArchitecture_ConcurrentSessions(t *testing.T) {
|
||||
// 测试多个 session 并发执行
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
sessionKey := fmt.Sprintf("session-%d", id)
|
||||
// 执行 agent loop
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestMixedArchitecture_SubTurnExecution(t *testing.T) {
|
||||
// 测试 SubTurn 执行
|
||||
// 1. 启动主 Turn
|
||||
// 2. 调用 spawn tool
|
||||
// 3. 验证 SubTurn 结果返回
|
||||
}
|
||||
|
||||
func TestMixedArchitecture_EventBusIntegration(t *testing.T) {
|
||||
// 测试事件系统
|
||||
// 1. 订阅事件
|
||||
// 2. 执行 Turn
|
||||
// 3. 验证事件触发
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 预期结果
|
||||
|
||||
完成后,系统应该:
|
||||
|
||||
✅ 支持多个 Session 并发执行
|
||||
✅ 支持 SubTurn 并发和嵌套
|
||||
✅ 所有操作都触发 EventBus 事件
|
||||
✅ Hook 系统正常工作
|
||||
✅ 代码结构清晰,易于维护
|
||||
|
||||
---
|
||||
|
||||
## 时间估算
|
||||
|
||||
- 步骤 1-2: 结构体合并 (40 分钟)
|
||||
- 步骤 3: turnState 更新 (20 分钟)
|
||||
- 步骤 4: runAgentLoop 重写 (1 小时)
|
||||
- 步骤 5: runTurn 调整 (30 分钟)
|
||||
- 步骤 6: 辅助函数 (30 分钟)
|
||||
- 步骤 7: SpawnSubTurn (30 分钟)
|
||||
- 步骤 8: 其他冲突 (1 小时)
|
||||
- 测试验证 (1 小时)
|
||||
|
||||
**总计: 约 5-6 小时**
|
||||
|
||||
---
|
||||
|
||||
## 风险和注意事项
|
||||
|
||||
1. **Context 传递**: 确保 SubTurn 的 context 正确继承父 context
|
||||
2. **Channel 关闭**: 确保 `pendingResults` channel 在合适的时机关闭
|
||||
3. **并发安全**: 所有对 turnState 的访问都要加锁
|
||||
4. **事件顺序**: 确保事件按正确顺序触发
|
||||
5. **测试覆盖**: 重点测试并发场景和 SubTurn 场景
|
||||
@@ -0,0 +1,271 @@
|
||||
# loop.go 冲突详细分析
|
||||
|
||||
## 概述
|
||||
|
||||
loop.go 有 11 处冲突,涉及核心架构差异:
|
||||
- **HEAD (feat/subturn-poc)**: 基于 context 的 SubTurn 层级管理,使用 `activeTurnStates` map 支持并发
|
||||
- **Incoming (refactor/agent)**: 事件驱动架构,使用 `EventBus`、`HookManager`,单个 `activeTurn` **不支持并发 turn**
|
||||
|
||||
## 关键发现:Incoming 的并发限制
|
||||
|
||||
**重要**: Incoming 分支的 `activeTurn` 设计**不支持并发 turn 执行**!
|
||||
|
||||
```go
|
||||
// Incoming 的实现
|
||||
func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, error) {
|
||||
al.registerActiveTurn(ts) // 设置 al.activeTurn = ts
|
||||
defer al.clearActiveTurn(ts) // 清除 al.activeTurn = nil
|
||||
// ...
|
||||
}
|
||||
|
||||
func (al *AgentLoop) registerActiveTurn(ts *turnState) {
|
||||
al.activeTurnMu.Lock()
|
||||
defer al.activeTurnMu.Unlock()
|
||||
al.activeTurn = ts // 单例!后面的会覆盖前面的
|
||||
}
|
||||
```
|
||||
|
||||
**问题**:
|
||||
1. 如果两个 session 同时调用 `runAgentLoop`,第二个会覆盖第一个的 `activeTurn`
|
||||
2. `GetActiveTurn()` 只能返回最后一个注册的 turn
|
||||
3. 中断操作 (`InterruptGraceful`, `InterruptHard`) 只能影响当前的 `activeTurn`
|
||||
|
||||
**HEAD 的优势**:
|
||||
```go
|
||||
// HEAD 的实现
|
||||
activeTurnStates sync.Map // 支持多个并发 turn
|
||||
// key: sessionKey, value: *turnState
|
||||
|
||||
// 每个 session 有独立的 turnState
|
||||
al.activeTurnStates.Store(opts.SessionKey, rootTS)
|
||||
```
|
||||
|
||||
## 架构决策的影响
|
||||
|
||||
如果采用 Incoming 的架构(方案 B),我们会**失去并发 turn 的能力**!
|
||||
|
||||
### 选项分析
|
||||
|
||||
**选项 1: 完全采用 Incoming(会失去并发)**
|
||||
- ✅ 获得事件驱动架构
|
||||
- ✅ 获得 Hook 系统
|
||||
- ❌ **失去并发 turn 支持**
|
||||
- ❌ **失去 SubTurn 并发支持**
|
||||
- ❌ 多个 session 无法同时处理
|
||||
|
||||
**选项 2: 混合方案(推荐)**
|
||||
- ✅ 保留 HEAD 的 `activeTurnStates sync.Map`
|
||||
- ✅ 采用 Incoming 的 `EventBus` 和 `HookManager`
|
||||
- ✅ 保持并发能力
|
||||
- ⚠️ 需要调整 `GetActiveTurn()` 等 API
|
||||
|
||||
**选项 3: 改造 Incoming 支持并发**
|
||||
- 将 `activeTurn *turnState` 改为 `activeTurns sync.Map`
|
||||
- 修改所有相关方法支持 sessionKey 参数
|
||||
- 工作量大,但架构更清晰
|
||||
|
||||
## 推荐方案:选项 2(混合方案)
|
||||
|
||||
### AgentLoop 结构体设计
|
||||
|
||||
```go
|
||||
type AgentLoop struct {
|
||||
// Incoming 的字段
|
||||
bus *bus.MessageBus
|
||||
cfg *config.Config
|
||||
registry *AgentRegistry
|
||||
state *state.Manager
|
||||
eventBus *EventBus // ✅ 保留
|
||||
hooks *HookManager // ✅ 保留
|
||||
hookRuntime hookRuntime // ✅ 保留
|
||||
running atomic.Bool
|
||||
summarizing sync.Map
|
||||
fallback *providers.FallbackChain
|
||||
channelManager *channels.Manager
|
||||
mediaStore media.MediaStore
|
||||
transcriber voice.Transcriber
|
||||
cmdRegistry *commands.Registry
|
||||
mcp mcpRuntime
|
||||
steering *steeringQueue
|
||||
mu sync.RWMutex
|
||||
|
||||
// HEAD 的并发支持(保留)
|
||||
activeTurnStates sync.Map // ✅ 保留:支持并发 turn
|
||||
subTurnCounter atomic.Int64 // ✅ 保留:SubTurn ID 生成
|
||||
|
||||
// Incoming 的字段(调整)
|
||||
turnSeq atomic.Uint64 // ✅ 保留:全局 turn 序列号
|
||||
activeRequests sync.WaitGroup // ✅ 保留:请求跟踪
|
||||
|
||||
reloadFunc func() error
|
||||
}
|
||||
```
|
||||
|
||||
### 关键方法调整
|
||||
|
||||
1. **GetActiveTurn()**: 需要接受 sessionKey 参数
|
||||
2. **InterruptGraceful/Hard()**: 需要接受 sessionKey 参数
|
||||
3. **runAgentLoop()**: 使用 `activeTurnStates` 而不是单个 `activeTurn`
|
||||
|
||||
## 冲突详情
|
||||
|
||||
### 冲突 1: AgentLoop 结构体 (38-77 行)
|
||||
|
||||
**HEAD 新增字段**:
|
||||
```go
|
||||
activeTurnStates sync.Map // key: sessionKey (string), value: *turnState
|
||||
subTurnCounter atomic.Int64 // Counter for generating unique SubTurn IDs
|
||||
```
|
||||
|
||||
**Incoming 新增字段**:
|
||||
```go
|
||||
eventBus *EventBus
|
||||
hooks *HookManager
|
||||
hookRuntime hookRuntime
|
||||
activeTurnMu sync.RWMutex
|
||||
activeTurn *turnState
|
||||
turnSeq atomic.Uint64
|
||||
activeRequests sync.WaitGroup
|
||||
```
|
||||
|
||||
**关键差异**:
|
||||
- HEAD: 使用 `sync.Map` 管理多个并发 turn (`activeTurnStates`)
|
||||
- Incoming: 使用单个 `activeTurn` + 锁 (`activeTurnMu`)
|
||||
- HEAD: SubTurn 计数器 (`subTurnCounter`)
|
||||
- Incoming: Turn 序列号 (`turnSeq`)
|
||||
- Incoming: 新增事件系统 (`eventBus`, `hooks`, `hookRuntime`)
|
||||
|
||||
**解决方案**: 采用 Incoming 的结构,但需要考虑如何在新架构中实现 SubTurn 的并发管理。
|
||||
|
||||
---
|
||||
|
||||
### 冲突 2: processOptions 结构体 (92-112 行)
|
||||
|
||||
**HEAD**:
|
||||
```go
|
||||
SkipAddUserMessage bool // If true, skip adding UserMessage to session history
|
||||
```
|
||||
|
||||
**Incoming**:
|
||||
```go
|
||||
InitialSteeringMessages []providers.Message
|
||||
|
||||
// 新增结构体
|
||||
type continuationTarget struct {
|
||||
SessionKey string
|
||||
Channel string
|
||||
ChatID string
|
||||
}
|
||||
```
|
||||
|
||||
**关键差异**:
|
||||
- HEAD: 使用 `SkipAddUserMessage` 标志
|
||||
- Incoming: 使用 `InitialSteeringMessages` 数组 + 新的 `continuationTarget` 结构体
|
||||
|
||||
**解决方案**: 采用 Incoming 的实现,`InitialSteeringMessages` 提供更灵活的 steering 消息处理。
|
||||
|
||||
---
|
||||
|
||||
### 冲突 3: runAgentLoop 函数 (1439-1581 行)
|
||||
|
||||
这是最大的冲突,涉及核心执行逻辑。
|
||||
|
||||
**HEAD 的实现**:
|
||||
1. 检查是否在 SubTurn 中 (`turnStateFromContext`)
|
||||
2. 如果是 SubTurn,复用现有 turnState
|
||||
3. 如果是根 turn,创建新的 rootTS
|
||||
4. 使用 `activeTurnStates.Store` 注册 turn
|
||||
5. 调用 `runLLMIteration` 执行 LLM 循环
|
||||
|
||||
**Incoming 的实现**:
|
||||
1. 记录 last channel
|
||||
2. 调用 `newTurnState` 创建 turn state
|
||||
3. 调用 `al.runTurn(ctx, ts)` 执行 turn
|
||||
4. 处理 follow-up 消息
|
||||
5. 发布响应
|
||||
|
||||
**关键差异**:
|
||||
- HEAD: 复杂的 SubTurn 层级管理,支持嵌套
|
||||
- Incoming: 简化的 turn 管理,通过 `newTurnState` 和 `runTurn`
|
||||
- HEAD: 使用 `runLLMIteration` 函数
|
||||
- Incoming: 使用 `runTurn` 函数
|
||||
- Incoming: 新增 follow-up 消息处理机制
|
||||
|
||||
**解决方案**: 采用 Incoming 的简化架构,但需要在 `runTurn` 中添加 SubTurn 支持。
|
||||
|
||||
---
|
||||
|
||||
### 冲突 4: runLLMIteration vs runTurn (1672-1689 行)
|
||||
|
||||
**HEAD**: 有独立的 `runLLMIteration` 函数
|
||||
**Incoming**: 使用 `runTurn` 函数
|
||||
|
||||
需要查看具体实现来决定如何合并。
|
||||
|
||||
---
|
||||
|
||||
### 冲突 5-11: 其他冲突点
|
||||
|
||||
剩余冲突主要涉及:
|
||||
- 工具执行逻辑
|
||||
- Steering 消息处理
|
||||
- 中断处理
|
||||
- 变量命名差异(`agent` vs `ts.agent`)
|
||||
|
||||
## 架构决策
|
||||
|
||||
根据方案 B(采用重构架构),需要:
|
||||
|
||||
1. **采用 Incoming 的 AgentLoop 结构**
|
||||
- 使用 `eventBus`, `hooks`, `hookRuntime`
|
||||
- 使用单个 `activeTurn` + `activeTurnMu`
|
||||
- 保留 `turnSeq`
|
||||
|
||||
2. **SubTurn 支持策略**
|
||||
- 选项 A: 在 `turnState` 中添加父子关系字段
|
||||
- 选项 B: 使用 context 传递 SubTurn 信息
|
||||
- 选项 C: 在 EventBus 中管理 SubTurn 层级
|
||||
|
||||
3. **函数迁移顺序**
|
||||
- 先采用 Incoming 的结构体定义
|
||||
- 更新 `newTurnState` 函数
|
||||
- 采用 `runTurn` 函数
|
||||
- 在 `runTurn` 中集成 SubTurn 逻辑
|
||||
|
||||
## 推荐实施步骤
|
||||
|
||||
### 步骤 1: 结构体定义 (30 分钟)
|
||||
- 采用 Incoming 的 `AgentLoop` 结构体
|
||||
- 采用 Incoming 的 `processOptions` 结构体
|
||||
- 添加 `continuationTarget` 结构体
|
||||
|
||||
### 步骤 2: 辅助函数 (30 分钟)
|
||||
- 更新 `NewAgentLoop` 初始化函数
|
||||
- 确保 EventBus、Hook 正确初始化
|
||||
|
||||
### 步骤 3: runAgentLoop 函数 (1-2 小时)
|
||||
- 采用 Incoming 的简化实现
|
||||
- 保留 channel 记录逻辑
|
||||
- 调用 `newTurnState` 和 `runTurn`
|
||||
- 处理 follow-up 消息
|
||||
|
||||
### 步骤 4: runTurn 函数 (2-3 小时)
|
||||
- 采用 Incoming 的 `runTurn` 实现
|
||||
- 在其中添加 SubTurn 检测和处理逻辑
|
||||
- 集成 SubTurn 结果回传机制
|
||||
|
||||
### 步骤 5: 其他冲突点 (1-2 小时)
|
||||
- 逐个解决剩余 7 个冲突
|
||||
- 确保变量命名一致
|
||||
- 更新工具执行和 steering 逻辑
|
||||
|
||||
## 风险和注意事项
|
||||
|
||||
1. **SubTurn 语义变化**: 新架构中 SubTurn 的实现方式可能不同
|
||||
2. **并发安全**: 从 `sync.Map` 迁移到单个 `activeTurn` + 锁
|
||||
3. **事件系统集成**: 需要确保 SubTurn 事件正确触发
|
||||
4. **测试覆盖**: 原有 SubTurn 测试需要更新
|
||||
|
||||
## 下一步
|
||||
|
||||
建议先实现步骤 1-2(结构体定义和初始化),然后再处理复杂的执行逻辑。
|
||||
+27
-16
@@ -222,13 +222,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 +429,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)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "fmt"
|
||||
|
||||
// MockEventBus - for POC
|
||||
var MockEventBus = struct {
|
||||
Emit func(event any)
|
||||
}{
|
||||
Emit: func(event any) {
|
||||
fmt.Printf("[Mock EventBus] %T %+v\n", event, event)
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
Model: "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,
|
||||
Model: "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,
|
||||
Model: "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,
|
||||
Model: "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,
|
||||
Model: "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(),
|
||||
Model: "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,
|
||||
Model: "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,
|
||||
|
||||
+1340
-526
File diff suppressed because it is too large
Load Diff
@@ -1078,11 +1078,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 +1120,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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+253
-69
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/routing"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
@@ -21,6 +22,9 @@ const (
|
||||
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.
|
||||
@@ -36,56 +40,117 @@ func parseSteeringMode(s string) SteeringMode {
|
||||
// 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
|
||||
queue []providers.Message
|
||||
mode SteeringMode
|
||||
mu sync.Mutex
|
||||
queues map[string][]providers.Message
|
||||
mode SteeringMode
|
||||
}
|
||||
|
||||
func newSteeringQueue(mode SteeringMode) *steeringQueue {
|
||||
return &steeringQueue{
|
||||
mode: mode,
|
||||
queues: make(map[string][]providers.Message),
|
||||
mode: mode,
|
||||
}
|
||||
}
|
||||
|
||||
// push enqueues a steering message.
|
||||
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()
|
||||
if len(sq.queue) >= MaxQueueSize {
|
||||
|
||||
scope = normalizeSteeringScope(scope)
|
||||
queue := sq.queues[scope]
|
||||
if len(queue) >= MaxQueueSize {
|
||||
return fmt.Errorf("steering queue is full")
|
||||
}
|
||||
sq.queue = append(sq.queue, msg)
|
||||
sq.queues[scope] = append(queue, msg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// dequeue removes and returns pending steering messages according to the
|
||||
// configured mode. Returns nil when the queue is empty.
|
||||
// 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()
|
||||
|
||||
if len(sq.queue) == 0 {
|
||||
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 := sq.queue
|
||||
sq.queue = nil
|
||||
msgs := append([]providers.Message(nil), queue...)
|
||||
delete(sq.queues, scope)
|
||||
return msgs
|
||||
default: // one-at-a-time
|
||||
msg := sq.queue[0]
|
||||
sq.queue[0] = providers.Message{} // Clear reference for GC
|
||||
sq.queue = sq.queue[1:]
|
||||
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.
|
||||
// len returns the number of queued messages across all scopes.
|
||||
func (sq *steeringQueue) len() int {
|
||||
sq.mu.Lock()
|
||||
defer sq.mu.Unlock()
|
||||
return len(sq.queue)
|
||||
|
||||
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.
|
||||
@@ -102,28 +167,76 @@ func (sq *steeringQueue) getMode() SteeringMode {
|
||||
return sq.mode
|
||||
}
|
||||
|
||||
// --- AgentLoop steering API ---
|
||||
|
||||
// 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.push(msg); err != nil {
|
||||
|
||||
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),
|
||||
"queue_len": al.steering.len(),
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -144,7 +257,7 @@ func (al *AgentLoop) SetSteeringMode(mode SteeringMode) {
|
||||
}
|
||||
|
||||
// dequeueSteeringMessages is the internal method called by the agent loop
|
||||
// to poll for steering messages. Returns nil when no messages are pending.
|
||||
// to poll for steering messages in the legacy fallback scope.
|
||||
func (al *AgentLoop) dequeueSteeringMessages() []providers.Message {
|
||||
if al.steering == nil {
|
||||
return nil
|
||||
@@ -152,6 +265,60 @@ func (al *AgentLoop) dequeueSteeringMessages() []providers.Message {
|
||||
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
|
||||
@@ -159,33 +326,74 @@ func (al *AgentLoop) dequeueSteeringMessages() []providers.Message {
|
||||
//
|
||||
// If no steering messages are pending, it returns an empty string.
|
||||
func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID string) (string, error) {
|
||||
steeringMsgs := al.dequeueSteeringMessages()
|
||||
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.GetRegistry().GetDefaultAgent()
|
||||
agent := al.agentForSession(sessionKey)
|
||||
if agent == nil {
|
||||
return "", fmt.Errorf("no default agent available")
|
||||
return "", fmt.Errorf("no agent available for session %q", sessionKey)
|
||||
}
|
||||
|
||||
// Build a combined user message from the steering messages.
|
||||
var contents []string
|
||||
for _, msg := range steeringMsgs {
|
||||
contents = append(contents, msg.Content)
|
||||
if tool, ok := agent.Tools.Get("message"); ok {
|
||||
if resetter, ok := tool.(interface{ ResetSentInRound() }); ok {
|
||||
resetter.ResetSentInRound()
|
||||
}
|
||||
}
|
||||
combinedContent := strings.Join(contents, "\n")
|
||||
|
||||
return al.runAgentLoop(ctx, agent, processOptions{
|
||||
SessionKey: sessionKey,
|
||||
Channel: channel,
|
||||
ChatID: chatID,
|
||||
UserMessage: combinedContent,
|
||||
DefaultResponse: defaultResponse,
|
||||
EnableSummary: true,
|
||||
SendResponse: false,
|
||||
SkipInitialSteeringPoll: true,
|
||||
})
|
||||
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 ======================
|
||||
@@ -206,7 +414,10 @@ func (al *AgentLoop) dequeuePendingSubTurnResults(sessionKey string) []*tools.To
|
||||
var results []*tools.ToolResult
|
||||
for {
|
||||
select {
|
||||
case result := <-ts.pendingResults:
|
||||
case result, ok := <-ts.pendingResults:
|
||||
if !ok {
|
||||
return results
|
||||
}
|
||||
if result != nil {
|
||||
results = append(results, result)
|
||||
}
|
||||
@@ -249,20 +460,6 @@ func (al *AgentLoop) HardAbort(sessionKey string) error {
|
||||
// Use isHardAbort=true for hard abort to immediately cancel all children.
|
||||
ts.Finish(true)
|
||||
|
||||
// Rollback session history to the state before this turn started.
|
||||
// This must happen AFTER Finish() to ensure no child turns are still writing.
|
||||
if ts.session != nil {
|
||||
currentHistory := ts.session.GetHistory("")
|
||||
if len(currentHistory) > ts.initialHistoryLength {
|
||||
logger.InfoCF("agent", "Rolling back session history", map[string]any{
|
||||
"from": len(currentHistory),
|
||||
"to": ts.initialHistoryLength,
|
||||
})
|
||||
// SetHistory with the truncated slice to rollback
|
||||
ts.session.SetHistory("", currentHistory[:ts.initialHistoryLength])
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -291,19 +488,6 @@ func (al *AgentLoop) InjectFollowUp(msg providers.Message) error {
|
||||
|
||||
// ====================== API Aliases for Design Document Compatibility ======================
|
||||
|
||||
// InterruptGraceful is an alias for Steer() to match the design document naming.
|
||||
// It gracefully interrupts the current execution by injecting a user message
|
||||
// that will be processed after the current tool finishes.
|
||||
func (al *AgentLoop) InterruptGraceful(msg providers.Message) error {
|
||||
return al.Steer(msg)
|
||||
}
|
||||
|
||||
// InterruptHard is an alias for HardAbort() to match the design document naming.
|
||||
// It immediately terminates execution and rolls back the session state.
|
||||
func (al *AgentLoop) InterruptHard(sessionKey string) error {
|
||||
return al.HardAbort(sessionKey)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -5,13 +5,18 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/bus"
|
||||
"github.com/sipeed/picoclaw/pkg/config"
|
||||
"github.com/sipeed/picoclaw/pkg/media"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/routing"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
@@ -335,6 +340,97 @@ func TestAgentLoop_Continue_WithMessages(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrainBusToSteering_RequeuesDifferentScopeMessage(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
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,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
Session: config.SessionConfig{
|
||||
DMScope: "per-peer",
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
al := NewAgentLoop(cfg, msgBus, &mockProvider{})
|
||||
|
||||
activeMsg := bus.InboundMessage{
|
||||
Channel: "telegram",
|
||||
SenderID: "user1",
|
||||
ChatID: "chat1",
|
||||
Content: "active turn",
|
||||
Peer: bus.Peer{
|
||||
Kind: "direct",
|
||||
ID: "user1",
|
||||
},
|
||||
}
|
||||
activeScope, activeAgentID, ok := al.resolveSteeringTarget(activeMsg)
|
||||
if !ok {
|
||||
t.Fatal("expected active message to resolve to a steering scope")
|
||||
}
|
||||
|
||||
otherMsg := bus.InboundMessage{
|
||||
Channel: "telegram",
|
||||
SenderID: "user2",
|
||||
ChatID: "chat2",
|
||||
Content: "other session",
|
||||
Peer: bus.Peer{
|
||||
Kind: "direct",
|
||||
ID: "user2",
|
||||
},
|
||||
}
|
||||
otherScope, _, ok := al.resolveSteeringTarget(otherMsg)
|
||||
if !ok {
|
||||
t.Fatal("expected other message to resolve to a steering scope")
|
||||
}
|
||||
if otherScope == activeScope {
|
||||
t.Fatalf("expected different steering scopes, got same scope %q", activeScope)
|
||||
}
|
||||
|
||||
if err := msgBus.PublishInbound(context.Background(), otherMsg); err != nil {
|
||||
t.Fatalf("PublishInbound failed: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
al.drainBusToSteering(ctx, activeScope, activeAgentID)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for drainBusToSteering to stop")
|
||||
}
|
||||
|
||||
if msgs := al.dequeueSteeringMessagesForScope(activeScope); len(msgs) != 0 {
|
||||
t.Fatalf("expected no steering messages for active scope, got %v", msgs)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("timeout waiting for requeued message on outbound bus")
|
||||
case requeued := <-msgBus.OutboundChan():
|
||||
if requeued.Channel != otherMsg.Channel || requeued.ChatID != otherMsg.ChatID ||
|
||||
requeued.Content != otherMsg.Content {
|
||||
t.Fatalf("requeued message mismatch: got %+v want %+v", requeued, otherMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// slowTool simulates a tool that takes some time to execute.
|
||||
type slowTool struct {
|
||||
name string
|
||||
@@ -396,6 +492,149 @@ func (m *toolCallProvider) GetDefaultModel() string {
|
||||
return "tool-call-mock"
|
||||
}
|
||||
|
||||
type gracefulCaptureProvider struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
toolCalls []providers.ToolCall
|
||||
finalResp string
|
||||
terminalMessages []providers.Message
|
||||
terminalToolsCount int
|
||||
}
|
||||
|
||||
func (p *gracefulCaptureProvider) 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: p.toolCalls,
|
||||
}, nil
|
||||
}
|
||||
|
||||
p.terminalMessages = append([]providers.Message(nil), messages...)
|
||||
p.terminalToolsCount = len(tools)
|
||||
return &providers.LLMResponse{
|
||||
Content: p.finalResp,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *gracefulCaptureProvider) GetDefaultModel() string {
|
||||
return "graceful-capture-mock"
|
||||
}
|
||||
|
||||
type lateSteeringProvider struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
firstCallStarted chan struct{}
|
||||
releaseFirstCall chan struct{}
|
||||
firstStartOnce sync.Once
|
||||
secondCallMessages []providers.Message
|
||||
}
|
||||
|
||||
func (p *lateSteeringProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
p.mu.Lock()
|
||||
p.calls++
|
||||
call := p.calls
|
||||
p.mu.Unlock()
|
||||
|
||||
if call == 1 {
|
||||
p.firstStartOnce.Do(func() { close(p.firstCallStarted) })
|
||||
<-p.releaseFirstCall
|
||||
return &providers.LLMResponse{Content: "first response"}, nil
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
p.secondCallMessages = append([]providers.Message(nil), messages...)
|
||||
p.mu.Unlock()
|
||||
return &providers.LLMResponse{Content: "continued response"}, nil
|
||||
}
|
||||
|
||||
func (p *lateSteeringProvider) GetDefaultModel() string {
|
||||
return "late-steering-mock"
|
||||
}
|
||||
|
||||
type blockingDirectProvider struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
firstStarted chan struct{}
|
||||
releaseFirst chan struct{}
|
||||
firstResp string
|
||||
finalResp string
|
||||
}
|
||||
|
||||
func (p *blockingDirectProvider) Chat(
|
||||
ctx context.Context,
|
||||
messages []providers.Message,
|
||||
tools []providers.ToolDefinition,
|
||||
model string,
|
||||
opts map[string]any,
|
||||
) (*providers.LLMResponse, error) {
|
||||
p.mu.Lock()
|
||||
p.calls++
|
||||
call := p.calls
|
||||
firstStarted := p.firstStarted
|
||||
releaseFirst := p.releaseFirst
|
||||
firstResp := p.firstResp
|
||||
finalResp := p.finalResp
|
||||
if call == 1 && p.firstStarted != nil {
|
||||
close(p.firstStarted)
|
||||
p.firstStarted = nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
if call == 1 {
|
||||
select {
|
||||
case <-releaseFirst:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
return &providers.LLMResponse{Content: firstResp}, nil
|
||||
}
|
||||
|
||||
_ = firstStarted
|
||||
return &providers.LLMResponse{Content: finalResp}, nil
|
||||
}
|
||||
|
||||
func (p *blockingDirectProvider) GetDefaultModel() string {
|
||||
return "blocking-direct-mock"
|
||||
}
|
||||
|
||||
type interruptibleTool struct {
|
||||
name string
|
||||
started chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (t *interruptibleTool) Name() string { return t.name }
|
||||
func (t *interruptibleTool) Description() string { return "interruptible tool for testing" }
|
||||
func (t *interruptibleTool) Parameters() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
func (t *interruptibleTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
|
||||
if t.started != nil {
|
||||
t.once.Do(func() { close(t.started) })
|
||||
}
|
||||
<-ctx.Done()
|
||||
return tools.ErrorResult(ctx.Err().Error()).WithError(ctx.Err())
|
||||
}
|
||||
|
||||
func TestAgentLoop_Steering_SkipsRemainingTools(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
if err != nil {
|
||||
@@ -568,6 +807,614 @@ func TestAgentLoop_Steering_InitialPoll(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLoop_Run_AutoContinuesLateSteeringMessage(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
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,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &lateSteeringProvider{
|
||||
firstCallStarted: make(chan struct{}),
|
||||
releaseFirstCall: make(chan struct{}),
|
||||
}
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
runCtx, cancelRun := context.WithCancel(context.Background())
|
||||
defer cancelRun()
|
||||
|
||||
runErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
runErrCh <- al.Run(runCtx)
|
||||
}()
|
||||
|
||||
first := bus.InboundMessage{
|
||||
Channel: "test",
|
||||
SenderID: "user1",
|
||||
ChatID: "chat1",
|
||||
Content: "first message",
|
||||
Peer: bus.Peer{
|
||||
Kind: "direct",
|
||||
ID: "user1",
|
||||
},
|
||||
}
|
||||
late := bus.InboundMessage{
|
||||
Channel: "test",
|
||||
SenderID: "user1",
|
||||
ChatID: "chat1",
|
||||
Content: "late append",
|
||||
Peer: bus.Peer{
|
||||
Kind: "direct",
|
||||
ID: "user1",
|
||||
},
|
||||
}
|
||||
|
||||
pubCtx, pubCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer pubCancel()
|
||||
if err := msgBus.PublishInbound(pubCtx, first); err != nil {
|
||||
t.Fatalf("publish first inbound: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-provider.firstCallStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for first provider call to start")
|
||||
}
|
||||
|
||||
if err := msgBus.PublishInbound(pubCtx, late); err != nil {
|
||||
t.Fatalf("publish late inbound: %v", err)
|
||||
}
|
||||
|
||||
close(provider.releaseFirstCall)
|
||||
|
||||
subCtx, subCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer subCancel()
|
||||
|
||||
var out1 bus.OutboundMessage
|
||||
select {
|
||||
case out1 = <-msgBus.OutboundChan():
|
||||
case <-subCtx.Done():
|
||||
t.Fatal("expected outbound response")
|
||||
}
|
||||
if out1.Content != "continued response" {
|
||||
t.Fatalf("expected continued response, got %q", out1.Content)
|
||||
}
|
||||
|
||||
noExtraCtx, cancelNoExtra := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancelNoExtra()
|
||||
select {
|
||||
case out2 := <-msgBus.OutboundChan():
|
||||
t.Fatalf("expected stale direct response to be suppressed, got extra outbound %q", out2.Content)
|
||||
case <-noExtraCtx.Done():
|
||||
}
|
||||
|
||||
cancelRun()
|
||||
select {
|
||||
case err := <-runErrCh:
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for Run to stop")
|
||||
}
|
||||
|
||||
provider.mu.Lock()
|
||||
calls := provider.calls
|
||||
secondMessages := append([]providers.Message(nil), provider.secondCallMessages...)
|
||||
provider.mu.Unlock()
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("expected 2 provider calls, got %d", calls)
|
||||
}
|
||||
|
||||
foundLateMessage := false
|
||||
for _, msg := range secondMessages {
|
||||
if msg.Role == "user" && msg.Content == "late append" {
|
||||
foundLateMessage = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundLateMessage {
|
||||
t.Fatal("expected queued late message to be processed in an automatic follow-up turn")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
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,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID)
|
||||
provider := &blockingDirectProvider{
|
||||
firstStarted: make(chan struct{}),
|
||||
releaseFirst: make(chan struct{}),
|
||||
firstResp: "stale direct response",
|
||||
finalResp: "fresh response after steering",
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
resultCh := make(chan struct {
|
||||
resp string
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
resp, err := al.ProcessDirectWithChannel(
|
||||
context.Background(),
|
||||
"initial request",
|
||||
sessionKey,
|
||||
"test",
|
||||
"chat1",
|
||||
)
|
||||
resultCh <- struct {
|
||||
resp string
|
||||
err error
|
||||
}{resp: resp, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-provider.firstStarted:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for first LLM call to start")
|
||||
}
|
||||
|
||||
if err := al.Steer(providers.Message{Role: "user", Content: "follow-up instruction"}); err != nil {
|
||||
t.Fatalf("Steer failed: %v", err)
|
||||
}
|
||||
close(provider.releaseFirst)
|
||||
|
||||
select {
|
||||
case result := <-resultCh:
|
||||
if result.err != nil {
|
||||
t.Fatalf("unexpected error: %v", result.err)
|
||||
}
|
||||
if result.resp != "fresh response after steering" {
|
||||
t.Fatalf("expected refreshed response, got %q", result.resp)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timeout waiting for ProcessDirectWithChannel")
|
||||
}
|
||||
|
||||
provider.mu.Lock()
|
||||
calls := provider.calls
|
||||
provider.mu.Unlock()
|
||||
if calls != 2 {
|
||||
t.Fatalf("expected 2 provider calls, got %d", calls)
|
||||
}
|
||||
|
||||
if msgs := al.dequeueSteeringMessagesForScope(sessionKey); len(msgs) != 0 {
|
||||
t.Fatalf("expected steering queue to be empty after continuation, got %v", msgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLoop_Continue_PreservesSteeringMedia(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
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,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
store := media.NewFileMediaStore()
|
||||
pngPath := filepath.Join(tmpDir, "steer.png")
|
||||
pngHeader := []byte{
|
||||
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
|
||||
0x00, 0x00, 0x00, 0x0D,
|
||||
0x49, 0x48, 0x44, 0x52,
|
||||
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02,
|
||||
0x00, 0x00, 0x00,
|
||||
0x90, 0x77, 0x53, 0xDE,
|
||||
}
|
||||
if err = os.WriteFile(pngPath, pngHeader, 0o644); err != nil {
|
||||
t.Fatalf("WriteFile failed: %v", err)
|
||||
}
|
||||
ref, err := store.Store(pngPath, media.MediaMeta{Filename: "steer.png", ContentType: "image/png"}, "test")
|
||||
if err != nil {
|
||||
t.Fatalf("Store failed: %v", err)
|
||||
}
|
||||
|
||||
var capturedMessages []providers.Message
|
||||
var capMu sync.Mutex
|
||||
provider := &capturingMockProvider{
|
||||
response: "ack",
|
||||
captureFn: func(msgs []providers.Message) {
|
||||
capMu.Lock()
|
||||
defer capMu.Unlock()
|
||||
capturedMessages = append([]providers.Message(nil), msgs...)
|
||||
},
|
||||
}
|
||||
|
||||
sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID)
|
||||
msgBus := bus.NewMessageBus()
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
al.SetMediaStore(store)
|
||||
|
||||
if err = al.Steer(providers.Message{
|
||||
Role: "user",
|
||||
Content: "describe this image",
|
||||
Media: []string{ref},
|
||||
}); err != nil {
|
||||
t.Fatalf("Steer failed: %v", err)
|
||||
}
|
||||
|
||||
resp, err := al.Continue(context.Background(), sessionKey, "test", "chat1")
|
||||
if err != nil {
|
||||
t.Fatalf("Continue failed: %v", err)
|
||||
}
|
||||
if resp != "ack" {
|
||||
t.Fatalf("expected ack, got %q", resp)
|
||||
}
|
||||
|
||||
capMu.Lock()
|
||||
msgs := append([]providers.Message(nil), capturedMessages...)
|
||||
capMu.Unlock()
|
||||
|
||||
foundResolvedMedia := false
|
||||
for _, msg := range msgs {
|
||||
if msg.Role != "user" || msg.Content != "describe this image" || len(msg.Media) != 1 {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(msg.Media[0], "data:image/png;base64,") {
|
||||
foundResolvedMedia = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundResolvedMedia {
|
||||
t.Fatal("expected continue path to inject steering media into the provider request")
|
||||
}
|
||||
|
||||
defaultAgent := al.registry.GetDefaultAgent()
|
||||
if defaultAgent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
history := defaultAgent.Sessions.GetHistory(sessionKey)
|
||||
foundOriginalRef := false
|
||||
for _, msg := range history {
|
||||
if msg.Role == "user" && len(msg.Media) == 1 && msg.Media[0] == ref {
|
||||
foundOriginalRef = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundOriginalRef {
|
||||
t.Fatal("expected original steering media ref to be preserved in session history")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLoop_InterruptGraceful_UsesTerminalNoToolCall(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
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,
|
||||
Model: "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 := &gracefulCaptureProvider{
|
||||
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: "graceful summary",
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
al.RegisterTool(tool1)
|
||||
al.RegisterTool(tool2)
|
||||
sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID)
|
||||
|
||||
sub := al.SubscribeEvents(32)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
|
||||
type result struct {
|
||||
resp string
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
go func() {
|
||||
resp, err := al.ProcessDirectWithChannel(
|
||||
context.Background(),
|
||||
"do something",
|
||||
sessionKey,
|
||||
"test",
|
||||
"chat1",
|
||||
)
|
||||
resultCh <- result{resp: resp, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-tool1ExecCh:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for tool_one to start")
|
||||
}
|
||||
|
||||
active := al.GetActiveTurn()
|
||||
if active == nil {
|
||||
t.Fatal("expected active turn while tool is running")
|
||||
}
|
||||
if active.SessionKey != sessionKey {
|
||||
t.Fatalf("expected active session %q, got %q", sessionKey, active.SessionKey)
|
||||
}
|
||||
if active.Channel != "test" || active.ChatID != "chat1" {
|
||||
t.Fatalf("unexpected active turn target: %#v", active)
|
||||
}
|
||||
|
||||
if err := al.InterruptGraceful("wrap it up"); err != nil {
|
||||
t.Fatalf("InterruptGraceful failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case r := <-resultCh:
|
||||
if r.err != nil {
|
||||
t.Fatalf("unexpected error: %v", r.err)
|
||||
}
|
||||
if r.resp != "graceful summary" {
|
||||
t.Fatalf("expected graceful summary, got %q", r.resp)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timeout waiting for graceful interrupt result")
|
||||
}
|
||||
|
||||
if active := al.GetActiveTurn(); active != nil {
|
||||
t.Fatalf("expected no active turn after completion, got %#v", active)
|
||||
}
|
||||
|
||||
provider.mu.Lock()
|
||||
terminalMessages := append([]providers.Message(nil), provider.terminalMessages...)
|
||||
terminalToolsCount := provider.terminalToolsCount
|
||||
calls := provider.calls
|
||||
provider.mu.Unlock()
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("expected 2 provider calls, got %d", calls)
|
||||
}
|
||||
if terminalToolsCount != 0 {
|
||||
t.Fatalf("expected graceful terminal call to disable tools, got %d tool defs", terminalToolsCount)
|
||||
}
|
||||
|
||||
foundHint := false
|
||||
foundSkipped := false
|
||||
expectedHint := "Interrupt requested. Stop scheduling tools and provide a short final summary.\n\n" +
|
||||
"Interrupt hint: wrap it up"
|
||||
for _, msg := range terminalMessages {
|
||||
if msg.Role == "user" && msg.Content == expectedHint {
|
||||
foundHint = true
|
||||
}
|
||||
if msg.Role == "tool" && msg.ToolCallID == "call_2" && msg.Content == "Skipped due to graceful interrupt." {
|
||||
foundSkipped = true
|
||||
}
|
||||
}
|
||||
if !foundHint {
|
||||
t.Fatal("expected graceful terminal call to include interrupt hint message")
|
||||
}
|
||||
if !foundSkipped {
|
||||
t.Fatal("expected remaining tool to be marked as skipped after graceful interrupt")
|
||||
}
|
||||
|
||||
events := collectEventStream(sub.C)
|
||||
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.Kind != InterruptKindGraceful {
|
||||
t.Fatalf("expected graceful interrupt payload, got %q", interruptPayload.Kind)
|
||||
}
|
||||
|
||||
turnEndEvt, ok := findEvent(events, EventKindTurnEnd)
|
||||
if !ok {
|
||||
t.Fatal("expected turn end event")
|
||||
}
|
||||
turnEndPayload, ok := turnEndEvt.Payload.(TurnEndPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected TurnEndPayload, got %T", turnEndEvt.Payload)
|
||||
}
|
||||
if turnEndPayload.Status != TurnEndStatusCompleted {
|
||||
t.Fatalf("expected completed turn after graceful interrupt, got %q", turnEndPayload.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||
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,
|
||||
Model: "test-model",
|
||||
MaxTokens: 4096,
|
||||
MaxToolIterations: 10,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
msgBus := bus.NewMessageBus()
|
||||
provider := &toolCallProvider{
|
||||
toolCalls: []providers.ToolCall{
|
||||
{
|
||||
ID: "call_1",
|
||||
Type: "function",
|
||||
Name: "cancel_tool",
|
||||
Function: &providers.FunctionCall{
|
||||
Name: "cancel_tool",
|
||||
Arguments: "{}",
|
||||
},
|
||||
Arguments: map[string]any{},
|
||||
},
|
||||
},
|
||||
finalResp: "should not happen",
|
||||
}
|
||||
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
started := make(chan struct{})
|
||||
al.RegisterTool(&interruptibleTool{name: "cancel_tool", started: started})
|
||||
sessionKey := routing.BuildAgentMainSessionKey(routing.DefaultAgentID)
|
||||
|
||||
defaultAgent := al.registry.GetDefaultAgent()
|
||||
if defaultAgent == nil {
|
||||
t.Fatal("expected default agent")
|
||||
}
|
||||
|
||||
originalHistory := []providers.Message{
|
||||
{Role: "user", Content: "before"},
|
||||
{Role: "assistant", Content: "after"},
|
||||
}
|
||||
defaultAgent.Sessions.SetHistory(sessionKey, originalHistory)
|
||||
|
||||
sub := al.SubscribeEvents(16)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
|
||||
type result struct {
|
||||
resp string
|
||||
err error
|
||||
}
|
||||
resultCh := make(chan result, 1)
|
||||
go func() {
|
||||
resp, err := al.ProcessDirectWithChannel(
|
||||
context.Background(),
|
||||
"do work",
|
||||
sessionKey,
|
||||
"test",
|
||||
"chat1",
|
||||
)
|
||||
resultCh <- result{resp: resp, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timeout waiting for interruptible tool to start")
|
||||
}
|
||||
|
||||
if active := al.GetActiveTurn(); active == nil {
|
||||
t.Fatal("expected active turn before hard abort")
|
||||
}
|
||||
|
||||
if err := al.InterruptHard(); err != nil {
|
||||
t.Fatalf("InterruptHard failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case r := <-resultCh:
|
||||
if r.err != nil {
|
||||
t.Fatalf("unexpected error: %v", r.err)
|
||||
}
|
||||
if r.resp != "" {
|
||||
t.Fatalf("expected no final response after hard abort, got %q", r.resp)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timeout waiting for hard abort result")
|
||||
}
|
||||
|
||||
if active := al.GetActiveTurn(); active != nil {
|
||||
t.Fatalf("expected no active turn after hard abort, got %#v", active)
|
||||
}
|
||||
|
||||
finalHistory := defaultAgent.Sessions.GetHistory(sessionKey)
|
||||
if !reflect.DeepEqual(finalHistory, originalHistory) {
|
||||
t.Fatalf("expected history rollback after hard abort, got %#v", finalHistory)
|
||||
}
|
||||
|
||||
events := collectEventStream(sub.C)
|
||||
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.Kind != InterruptKindHard {
|
||||
t.Fatalf("expected hard interrupt payload, got %q", interruptPayload.Kind)
|
||||
}
|
||||
|
||||
turnEndEvt, ok := findEvent(events, EventKindTurnEnd)
|
||||
if !ok {
|
||||
t.Fatal("expected turn end event")
|
||||
}
|
||||
turnEndPayload, ok := turnEndEvt.Payload.(TurnEndPayload)
|
||||
if !ok {
|
||||
t.Fatalf("expected TurnEndPayload, got %T", turnEndEvt.Payload)
|
||||
}
|
||||
if turnEndPayload.Status != TurnEndStatusAborted {
|
||||
t.Fatalf("expected aborted turn, got %q", turnEndPayload.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// capturingMockProvider captures messages sent to Chat for inspection.
|
||||
type capturingMockProvider struct {
|
||||
response string
|
||||
|
||||
+237
-332
@@ -4,14 +4,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/logger"
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
"github.com/sipeed/picoclaw/pkg/utils"
|
||||
)
|
||||
|
||||
// ====================== Config & Constants ======================
|
||||
@@ -176,33 +175,6 @@ type SubTurnConfig struct {
|
||||
// Can be extended with temperature, topP, etc.
|
||||
}
|
||||
|
||||
// ====================== Sub-turn Events (Aligned with EventBus) ======================
|
||||
|
||||
// SubTurnSpawnEvent is emitted when a child sub-turn is started.
|
||||
type SubTurnSpawnEvent struct {
|
||||
ParentID string
|
||||
ChildID string
|
||||
Config SubTurnConfig
|
||||
}
|
||||
|
||||
type SubTurnEndEvent struct {
|
||||
ChildID string
|
||||
Result *tools.ToolResult
|
||||
Err error
|
||||
}
|
||||
|
||||
type SubTurnResultDeliveredEvent struct {
|
||||
ParentID string
|
||||
ChildID string
|
||||
Result *tools.ToolResult
|
||||
}
|
||||
|
||||
type SubTurnOrphanResultEvent struct {
|
||||
ParentID string
|
||||
ChildID string
|
||||
Result *tools.ToolResult
|
||||
}
|
||||
|
||||
// ====================== Context Keys ======================
|
||||
type agentLoopKeyType struct{}
|
||||
|
||||
@@ -300,6 +272,11 @@ func spawnSubTurn(
|
||||
// 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
|
||||
@@ -353,10 +330,60 @@ func spawnSubTurn(
|
||||
defer cancel()
|
||||
|
||||
childID := al.generateSubTurnID()
|
||||
childTS := newTurnState(childCtx, childID, parentTS, rtCfg.maxConcurrent)
|
||||
// Set the cancel function so Finish(true) can trigger hard cancellation
|
||||
|
||||
// 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.
|
||||
@@ -376,6 +403,8 @@ func spawnSubTurn(
|
||||
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)
|
||||
@@ -386,11 +415,14 @@ func spawnSubTurn(
|
||||
parentTS.mu.Unlock()
|
||||
|
||||
// 6. Emit Spawn event
|
||||
MockEventBus.Emit(SubTurnSpawnEvent{
|
||||
ParentID: parentTS.turnID,
|
||||
ChildID: childID,
|
||||
Config: cfg,
|
||||
})
|
||||
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() {
|
||||
@@ -401,22 +433,61 @@ func spawnSubTurn(
|
||||
"parent_id": parentTS.turnID,
|
||||
"panic": r,
|
||||
})
|
||||
|
||||
// Ensure result is not nil to prevent panic during event emission
|
||||
if result == nil {
|
||||
result = &tools.ToolResult{
|
||||
Err: err,
|
||||
ForLLM: fmt.Sprintf("SubTurn panicked: %v", r),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Result Delivery Strategy (Async vs Sync)
|
||||
if cfg.Async {
|
||||
deliverSubTurnResult(parentTS, childID, result)
|
||||
deliverSubTurnResult(al, parentTS, childID, result)
|
||||
}
|
||||
|
||||
MockEventBus.Emit(SubTurnEndEvent{
|
||||
ChildID: childID,
|
||||
Result: result,
|
||||
Err: err,
|
||||
})
|
||||
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.
|
||||
result, err = runTurn(childCtx, al, childTS, cfg)
|
||||
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
|
||||
}
|
||||
@@ -441,7 +512,7 @@ func spawnSubTurn(
|
||||
// Event emissions:
|
||||
// - SubTurnResultDeliveredEvent: successful delivery to channel
|
||||
// - SubTurnOrphanResultEvent: delivery failed (parent finished or channel full)
|
||||
func deliverSubTurnResult(parentTS *turnState, childID string, result *tools.ToolResult) {
|
||||
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() {
|
||||
@@ -451,28 +522,26 @@ func deliverSubTurnResult(parentTS *turnState, childID string, result *tools.Too
|
||||
"child_id": childID,
|
||||
"recover": r,
|
||||
})
|
||||
if result != nil {
|
||||
MockEventBus.Emit(SubTurnOrphanResultEvent{
|
||||
ParentID: parentTS.turnID,
|
||||
ChildID: childID,
|
||||
Result: result,
|
||||
})
|
||||
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
|
||||
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 {
|
||||
MockEventBus.Emit(SubTurnOrphanResultEvent{
|
||||
ParentID: parentTS.turnID,
|
||||
ChildID: childID,
|
||||
Result: result,
|
||||
})
|
||||
if result != nil && al != nil {
|
||||
al.emitEvent(EventKindSubTurnOrphan,
|
||||
parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"),
|
||||
SubTurnOrphanPayload{ParentTurnID: parentTS.turnID, ChildTurnID: childID, Reason: "parent_finished"},
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -484,11 +553,12 @@ func deliverSubTurnResult(parentTS *turnState, childID string, result *tools.Too
|
||||
select {
|
||||
case resultChan <- result:
|
||||
// Successfully delivered
|
||||
MockEventBus.Emit(SubTurnResultDeliveredEvent{
|
||||
ParentID: parentTS.turnID,
|
||||
ChildID: childID,
|
||||
Result: result,
|
||||
})
|
||||
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.
|
||||
@@ -496,278 +566,113 @@ func deliverSubTurnResult(parentTS *turnState, childID string, result *tools.Too
|
||||
"parent_id": parentTS.turnID,
|
||||
"child_id": childID,
|
||||
})
|
||||
if result != nil {
|
||||
MockEventBus.Emit(SubTurnOrphanResultEvent{
|
||||
ParentID: parentTS.turnID,
|
||||
ChildID: childID,
|
||||
Result: result,
|
||||
})
|
||||
if result != nil && al != nil {
|
||||
al.emitEvent(
|
||||
EventKindSubTurnOrphan,
|
||||
parentTS.eventMeta("deliverSubTurnResult", "subturn.orphan"),
|
||||
SubTurnOrphanPayload{
|
||||
ParentTurnID: parentTS.turnID,
|
||||
ChildTurnID: childID,
|
||||
Reason: "parent_finished_waiting",
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runTurn builds a temporary AgentInstance from SubTurnConfig and delegates to
|
||||
// the real agent loop. The child's ephemeral session is used for history so it
|
||||
// never pollutes the parent session.
|
||||
//
|
||||
// This function implements multiple layers of context protection and error recovery:
|
||||
//
|
||||
// 1. Soft Context Limit (MaxContextRunes):
|
||||
// - Proactively truncates message history before LLM calls
|
||||
// - Default: 75% of model's context window
|
||||
// - Preserves system messages and recent context
|
||||
// - First line of defense against context overflow
|
||||
//
|
||||
// 2. Hard Context Error Recovery:
|
||||
// - Detects context_length_exceeded errors from provider
|
||||
// - Triggers force compression and retries (up to 2 times)
|
||||
// - Second line of defense when soft limit is insufficient
|
||||
//
|
||||
// 3. Truncation Recovery:
|
||||
// - Detects when LLM response is truncated (finish_reason="truncated")
|
||||
// - Injects recovery prompt asking for shorter response
|
||||
// - Retries up to 2 times
|
||||
// - Handles cases where max_tokens is hit
|
||||
func runTurn(
|
||||
ctx context.Context,
|
||||
al *AgentLoop,
|
||||
ts *turnState,
|
||||
cfg SubTurnConfig,
|
||||
) (*tools.ToolResult, error) {
|
||||
// Derive candidates from the requested model using the parent loop's provider.
|
||||
defaultProvider := al.GetConfig().Agents.Defaults.Provider
|
||||
candidates := providers.ResolveCandidates(
|
||||
providers.ModelConfig{Primary: cfg.Model},
|
||||
defaultProvider,
|
||||
)
|
||||
|
||||
// Build a minimal AgentInstance for this sub-turn.
|
||||
// It reuses the parent loop's provider and config, but gets its own
|
||||
// ephemeral session store and tool registry.
|
||||
parentAgent := al.GetRegistry().GetDefaultAgent()
|
||||
|
||||
// Determine which tools to use: explicit config or inherit from parent
|
||||
toolRegistry := tools.NewToolRegistry()
|
||||
toolsToRegister := cfg.Tools
|
||||
if len(toolsToRegister) == 0 {
|
||||
toolsToRegister = parentAgent.Tools.GetAll()
|
||||
}
|
||||
for _, t := range toolsToRegister {
|
||||
toolRegistry.Register(t)
|
||||
}
|
||||
|
||||
childAgent := &AgentInstance{
|
||||
ID: ts.turnID,
|
||||
Model: cfg.Model,
|
||||
MaxIterations: parentAgent.MaxIterations,
|
||||
MaxTokens: cfg.MaxTokens,
|
||||
Temperature: parentAgent.Temperature,
|
||||
ThinkingLevel: parentAgent.ThinkingLevel,
|
||||
ContextWindow: parentAgent.ContextWindow, // Inherit from parent agent
|
||||
SummarizeMessageThreshold: parentAgent.SummarizeMessageThreshold,
|
||||
SummarizeTokenPercent: parentAgent.SummarizeTokenPercent,
|
||||
Provider: parentAgent.Provider,
|
||||
Sessions: ts.session,
|
||||
ContextBuilder: parentAgent.ContextBuilder,
|
||||
Tools: toolRegistry,
|
||||
Candidates: candidates,
|
||||
}
|
||||
if childAgent.MaxTokens == 0 {
|
||||
childAgent.MaxTokens = parentAgent.MaxTokens
|
||||
}
|
||||
|
||||
promptAlreadyAdded := false
|
||||
|
||||
// Preload ephemeral session history
|
||||
if len(cfg.InitialMessages) > 0 {
|
||||
existing := childAgent.Sessions.GetHistory(ts.turnID)
|
||||
childAgent.Sessions.SetHistory(ts.turnID, append(existing, cfg.InitialMessages...))
|
||||
promptAlreadyAdded = true // InitialMessages 中已含 user 消息,跳过再次添加
|
||||
}
|
||||
|
||||
// Resolve MaxContextRunes configuration
|
||||
maxContextRunes := utils.ResolveMaxContextRunes(cfg.MaxContextRunes, childAgent.ContextWindow)
|
||||
|
||||
logger.DebugCF("subturn", "Context limit resolved",
|
||||
map[string]any{
|
||||
"turn_id": ts.turnID,
|
||||
"context_window": childAgent.ContextWindow,
|
||||
"max_context_runes": maxContextRunes,
|
||||
"configured_value": cfg.MaxContextRunes,
|
||||
})
|
||||
|
||||
// Retry loop for truncation and context errors
|
||||
const (
|
||||
maxTruncationRetries = 2
|
||||
maxContextRetries = 2
|
||||
)
|
||||
|
||||
truncationRetryCount := 0
|
||||
contextRetryCount := 0
|
||||
currentPrompt := cfg.SystemPrompt
|
||||
|
||||
for {
|
||||
// Soft context limit: check and truncate before LLM call
|
||||
if maxContextRunes > 0 {
|
||||
messages := childAgent.Sessions.GetHistory(ts.turnID)
|
||||
currentRunes := utils.MeasureContextRunes(messages)
|
||||
|
||||
if currentRunes > maxContextRunes {
|
||||
logger.WarnCF("subturn", "Context exceeds soft limit, truncating",
|
||||
map[string]any{
|
||||
"turn_id": ts.turnID,
|
||||
"current_runes": currentRunes,
|
||||
"max_runes": maxContextRunes,
|
||||
"overflow": currentRunes - maxContextRunes,
|
||||
})
|
||||
|
||||
truncatedMessages := utils.TruncateContextSmart(messages, maxContextRunes)
|
||||
childAgent.Sessions.SetHistory(ts.turnID, truncatedMessages)
|
||||
|
||||
// Log truncation result
|
||||
newRunes := utils.MeasureContextRunes(truncatedMessages)
|
||||
logger.InfoCF("subturn", "Context truncated successfully",
|
||||
map[string]any{
|
||||
"turn_id": ts.turnID,
|
||||
"before_runes": currentRunes,
|
||||
"after_runes": newRunes,
|
||||
"saved_runes": currentRunes - newRunes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Call the agent loop
|
||||
finalContent, err := al.runAgentLoop(ctx, childAgent, processOptions{
|
||||
SessionKey: ts.turnID,
|
||||
UserMessage: currentPrompt,
|
||||
SystemPromptOverride: cfg.ActualSystemPrompt,
|
||||
DefaultResponse: "",
|
||||
EnableSummary: false,
|
||||
SendResponse: false,
|
||||
SkipAddUserMessage: promptAlreadyAdded,
|
||||
})
|
||||
|
||||
// Mark the prompt as added so subsequent truncation retries
|
||||
// won't duplicate it in the history.
|
||||
promptAlreadyAdded = true
|
||||
|
||||
// 1. Handle context length errors
|
||||
if err != nil && isContextLengthError(err) {
|
||||
if contextRetryCount >= maxContextRetries {
|
||||
logger.ErrorCF("subturn", "Context limit exceeded after max retries",
|
||||
map[string]any{
|
||||
"turn_id": ts.turnID,
|
||||
"retries": contextRetryCount,
|
||||
"max_retries": maxContextRetries,
|
||||
})
|
||||
return nil, fmt.Errorf(
|
||||
"context limit exceeded after %d retries: %w",
|
||||
maxContextRetries,
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
logger.WarnCF("subturn", "Context length exceeded, compressing and retrying",
|
||||
map[string]any{
|
||||
"turn_id": ts.turnID,
|
||||
"retry": contextRetryCount + 1,
|
||||
})
|
||||
|
||||
// Trigger force compression
|
||||
al.forceCompression(childAgent, ts.turnID)
|
||||
|
||||
contextRetryCount++
|
||||
continue // Retry with compressed history
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err // Other errors, return immediately
|
||||
}
|
||||
|
||||
// 2. Check for truncation (retrieve finishReason from turnState)
|
||||
finishReason := ts.GetLastFinishReason()
|
||||
|
||||
if finishReason == "truncated" && truncationRetryCount < maxTruncationRetries {
|
||||
logger.WarnCF("subturn", "Response truncated, injecting recovery message",
|
||||
map[string]any{
|
||||
"turn_id": ts.turnID,
|
||||
"retry": truncationRetryCount + 1,
|
||||
})
|
||||
|
||||
// IMPORTANT: Do NOT manually add messages to history here.
|
||||
// runAgentLoop has already saved both the assistant message (finalContent)
|
||||
// and will save the next user message (currentPrompt) on the next iteration.
|
||||
// Manually adding them would cause duplicates.
|
||||
|
||||
// Inject recovery prompt - it will be added by runAgentLoop on next iteration
|
||||
recoveryPrompt := "Your previous response was truncated due to length. Please provide a shorter, complete response that finishes your thought."
|
||||
currentPrompt = recoveryPrompt
|
||||
promptAlreadyAdded = false // We need this new recovery prompt to be added
|
||||
|
||||
truncationRetryCount++
|
||||
continue // Retry with recovery prompt
|
||||
}
|
||||
|
||||
// 3. Token budget enforcement (if configured)
|
||||
// Check if budget is exhausted after this LLM call. If so, return gracefully
|
||||
// with current result instead of continuing iterations.
|
||||
if ts.tokenBudget != nil {
|
||||
if usage := ts.GetLastUsage(); usage != nil {
|
||||
newBudget := ts.tokenBudget.Add(-int64(usage.TotalTokens))
|
||||
|
||||
if newBudget <= 0 {
|
||||
logger.WarnCF("subturn", "Token budget exhausted",
|
||||
map[string]any{
|
||||
"turn_id": ts.turnID,
|
||||
"deficit": -newBudget,
|
||||
"tokens_used": usage.TotalTokens,
|
||||
"final_budget": newBudget,
|
||||
})
|
||||
|
||||
// Budget exhausted - return current result with marker
|
||||
return &tools.ToolResult{
|
||||
ForLLM: finalContent + "\n\n[Token budget exhausted]",
|
||||
Messages: childAgent.Sessions.GetHistory(ts.turnID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
logger.DebugCF("subturn", "Token budget updated",
|
||||
map[string]any{
|
||||
"turn_id": ts.turnID,
|
||||
"tokens_used": usage.TotalTokens,
|
||||
"remaining_budget": newBudget,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Success - return result with session history
|
||||
return &tools.ToolResult{
|
||||
ForLLM: finalContent,
|
||||
Messages: childAgent.Sessions.GetHistory(ts.turnID),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// isContextLengthError checks if the error is due to context length exceeded.
|
||||
// It excludes timeout errors to avoid false positives.
|
||||
func isContextLengthError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errMsg := strings.ToLower(err.Error())
|
||||
|
||||
// Exclude timeout errors
|
||||
if strings.Contains(errMsg, "timeout") || strings.Contains(errMsg, "deadline exceeded") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Detect context error patterns
|
||||
return strings.Contains(errMsg, "context_length_exceeded") ||
|
||||
strings.Contains(errMsg, "maximum context length") ||
|
||||
strings.Contains(errMsg, "context window") ||
|
||||
strings.Contains(errMsg, "too many tokens") ||
|
||||
strings.Contains(errMsg, "token limit") ||
|
||||
strings.Contains(errMsg, "prompt is too long")
|
||||
}
|
||||
|
||||
// ====================== 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:]
|
||||
}
|
||||
}
|
||||
|
||||
+214
-173
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -22,17 +21,35 @@ const (
|
||||
|
||||
// ====================== Test Helper: Event Collector ======================
|
||||
type eventCollector struct {
|
||||
events []any
|
||||
mu sync.Mutex
|
||||
events []Event
|
||||
}
|
||||
|
||||
func (c *eventCollector) collect(e any) {
|
||||
c.events = append(c.events, e)
|
||||
func newEventCollector(t *testing.T, al *AgentLoop) (*eventCollector, func()) {
|
||||
t.Helper()
|
||||
c := &eventCollector{}
|
||||
sub := al.SubscribeEvents(16)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for evt := range sub.C {
|
||||
c.mu.Lock()
|
||||
c.events = append(c.events, evt)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
cleanup := func() {
|
||||
al.UnsubscribeEvents(sub.ID)
|
||||
<-done
|
||||
}
|
||||
return c, cleanup
|
||||
}
|
||||
|
||||
func (c *eventCollector) hasEventOfType(typ any) bool {
|
||||
targetType := reflect.TypeOf(typ)
|
||||
func (c *eventCollector) hasEventOfKind(kind EventKind) bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, e := range c.events {
|
||||
if reflect.TypeOf(e) == targetType {
|
||||
if e.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -111,13 +128,12 @@ func TestSpawnSubTurn(t *testing.T) {
|
||||
childTurnIDs: []string{},
|
||||
pendingResults: make(chan *tools.ToolResult, 10),
|
||||
session: &ephemeralSessionStore{},
|
||||
agent: al.registry.GetDefaultAgent(),
|
||||
}
|
||||
|
||||
// Replace mock with test collector
|
||||
collector := &eventCollector{}
|
||||
originalEmit := MockEventBus.Emit
|
||||
MockEventBus.Emit = collector.collect
|
||||
defer func() { MockEventBus.Emit = originalEmit }()
|
||||
// Subscribe to real EventBus to capture events
|
||||
collector, collectCleanup := newEventCollector(t, al)
|
||||
defer collectCleanup()
|
||||
|
||||
// Execute spawnSubTurn
|
||||
result, err := spawnSubTurn(context.Background(), al, parent, tt.config)
|
||||
@@ -140,13 +156,14 @@ func TestSpawnSubTurn(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify event emission
|
||||
time.Sleep(10 * time.Millisecond) // let event goroutine flush
|
||||
if tt.wantSpawn {
|
||||
if !collector.hasEventOfType(SubTurnSpawnEvent{}) {
|
||||
if !collector.hasEventOfKind(EventKindSubTurnSpawn) {
|
||||
t.Error("SubTurnSpawnEvent not emitted")
|
||||
}
|
||||
}
|
||||
if tt.wantEnd {
|
||||
if !collector.hasEventOfType(SubTurnEndEvent{}) {
|
||||
if !collector.hasEventOfKind(EventKindSubTurnEnd) {
|
||||
t.Error("SubTurnEndEvent not emitted")
|
||||
}
|
||||
}
|
||||
@@ -169,27 +186,41 @@ func TestSpawnSubTurn_EphemeralSessionIsolation(t *testing.T) {
|
||||
_ = provider
|
||||
defer cleanup()
|
||||
|
||||
// Parent uses its own ephemeral store pre-seeded with one message
|
||||
parentSession := &ephemeralSessionStore{}
|
||||
parentSession.AddMessage("", "user", "parent msg")
|
||||
parent := &turnState{
|
||||
ctx: context.Background(),
|
||||
turnID: "parent-1",
|
||||
depth: 0,
|
||||
pendingResults: make(chan *tools.ToolResult, 1),
|
||||
pendingResults: make(chan *tools.ToolResult, 4),
|
||||
concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns),
|
||||
session: parentSession,
|
||||
}
|
||||
|
||||
cfg := SubTurnConfig{Model: "gpt-4o-mini", Tools: []tools.Tool{}}
|
||||
|
||||
// Record main session length before execution
|
||||
originalLen := len(parent.session.GetHistory(""))
|
||||
originalParentLen := len(parentSession.GetHistory(""))
|
||||
|
||||
_, _ = spawnSubTurn(context.Background(), al, parent, cfg)
|
||||
|
||||
// After sub-turn ends, main session must remain unchanged
|
||||
if len(parent.session.GetHistory("")) != originalLen {
|
||||
t.Error("ephemeral session polluted the main session")
|
||||
// Parent session must be untouched — child used its own store
|
||||
if got := len(parentSession.GetHistory("")); got != originalParentLen {
|
||||
t.Errorf("parent session polluted: expected %d messages, got %d", originalParentLen, got)
|
||||
}
|
||||
|
||||
// The child's agent.Sessions must NOT be the same pointer as the parent's session.
|
||||
// We verify this indirectly: spawnSubTurn stores childTS in activeTurnStates during
|
||||
// execution (deleted on return), so we can't easily grab childTS after the call.
|
||||
// Instead, confirm that the child session is a distinct ephemeralSessionStore by
|
||||
// checking the parent session key is only used by the parent store.
|
||||
// If isolation is correct, parent.session.GetHistory(childID) is always empty
|
||||
// (the child never wrote to the parent store).
|
||||
al.activeTurnStates.Range(func(k, v any) bool {
|
||||
// No active turns should remain after spawnSubTurn returns
|
||||
t.Errorf("unexpected active turn state left after spawnSubTurn: key=%v", k)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// ====================== Extra Independent Test: Result Delivery Path (Async) ======================
|
||||
@@ -260,6 +291,13 @@ func TestSpawnSubTurn_ResultDeliverySync(t *testing.T) {
|
||||
|
||||
// ====================== Extra Independent Test: Orphan Result Routing ======================
|
||||
func TestSpawnSubTurn_OrphanResultRouting(t *testing.T) {
|
||||
al, _, _, provider, cleanup := newTestAgentLoop(t)
|
||||
_ = provider
|
||||
defer cleanup()
|
||||
|
||||
collector, collectCleanup := newEventCollector(t, al)
|
||||
defer collectCleanup()
|
||||
|
||||
parentCtx, cancelParent := context.WithCancel(context.Background())
|
||||
parent := &turnState{
|
||||
ctx: parentCtx,
|
||||
@@ -270,19 +308,15 @@ func TestSpawnSubTurn_OrphanResultRouting(t *testing.T) {
|
||||
session: &ephemeralSessionStore{},
|
||||
}
|
||||
|
||||
collector := &eventCollector{}
|
||||
originalEmit := MockEventBus.Emit
|
||||
MockEventBus.Emit = collector.collect
|
||||
defer func() { MockEventBus.Emit = originalEmit }()
|
||||
|
||||
// Simulate parent finishing before child delivers result
|
||||
parent.Finish(false)
|
||||
|
||||
// Call deliverSubTurnResult directly to simulate a delayed child
|
||||
deliverSubTurnResult(parent, "delayed-child", &tools.ToolResult{ForLLM: "late result"})
|
||||
deliverSubTurnResult(al, parent, "delayed-child", &tools.ToolResult{ForLLM: "late result"})
|
||||
|
||||
time.Sleep(10 * time.Millisecond) // let event goroutine flush
|
||||
// Verify Orphan event is emitted
|
||||
if !collector.hasEventOfType(SubTurnOrphanResultEvent{}) {
|
||||
if !collector.hasEventOfKind(EventKindSubTurnOrphan) {
|
||||
t.Error("SubTurnOrphanResultEvent not emitted for finished parent")
|
||||
}
|
||||
|
||||
@@ -414,70 +448,74 @@ func TestHardAbortCascading(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
sessionKey := "test-session-abort"
|
||||
parentCtx, parentCancel := context.WithCancel(context.Background())
|
||||
defer parentCancel()
|
||||
|
||||
// Root turn with its own independent context (not derived from child)
|
||||
rootCtx, rootCancel := context.WithCancel(context.Background())
|
||||
rootTS := &turnState{
|
||||
ctx: parentCtx,
|
||||
ctx: rootCtx,
|
||||
cancelFunc: rootCancel,
|
||||
turnID: sessionKey,
|
||||
depth: 0,
|
||||
session: &ephemeralSessionStore{},
|
||||
pendingResults: make(chan *tools.ToolResult, 16),
|
||||
concurrencySem: make(chan struct{}, 5),
|
||||
al: al,
|
||||
}
|
||||
|
||||
// Register the root turn state
|
||||
al.activeTurnStates.Store(sessionKey, rootTS)
|
||||
defer al.activeTurnStates.Delete(sessionKey)
|
||||
|
||||
// Create a child turn state
|
||||
childCtx, childCancel := context.WithCancel(rootTS.ctx)
|
||||
defer childCancel()
|
||||
// Child turn with an INDEPENDENT context (simulates spawnSubTurn behavior:
|
||||
// context.WithTimeout(context.Background(), ...) — NOT derived from parent).
|
||||
// Cascade must therefore happen via childTurnIDs traversal, not Go context tree.
|
||||
childCtx, childCancel := context.WithCancel(context.Background())
|
||||
childID := "child-independent"
|
||||
childTS := &turnState{
|
||||
ctx: childCtx,
|
||||
ctx: childCtx,
|
||||
cancelFunc: childCancel,
|
||||
turnID: childID,
|
||||
pendingResults: make(chan *tools.ToolResult, 4),
|
||||
al: al,
|
||||
}
|
||||
_ = childCancel
|
||||
al.activeTurnStates.Store(childID, childTS)
|
||||
defer al.activeTurnStates.Delete(childID)
|
||||
|
||||
// Attach cancelFunc to rootTS so Finish() can trigger it
|
||||
rootTS.cancelFunc = parentCancel
|
||||
// Wire child into root's childTurnIDs (as spawnSubTurn would do)
|
||||
rootTS.childTurnIDs = append(rootTS.childTurnIDs, childID)
|
||||
|
||||
// Verify contexts are not canceled yet
|
||||
// Verify neither context is canceled yet
|
||||
select {
|
||||
case <-rootTS.ctx.Done():
|
||||
t.Error("root context should not be canceled yet")
|
||||
t.Fatal("root context should not be canceled yet")
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-childTS.ctx.Done():
|
||||
t.Error("child context should not be canceled yet")
|
||||
t.Fatal("child context should not be canceled yet (independent context)")
|
||||
default:
|
||||
}
|
||||
|
||||
// Trigger Hard Abort
|
||||
// Trigger Hard Abort via al.HardAbort (goes through steering.go → Finish(true))
|
||||
err := al.HardAbort(sessionKey)
|
||||
if err != nil {
|
||||
t.Errorf("HardAbort failed: %v", err)
|
||||
t.Fatalf("HardAbort failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify root context is canceled
|
||||
// Root context must be canceled
|
||||
select {
|
||||
case <-rootTS.ctx.Done():
|
||||
// Expected
|
||||
default:
|
||||
t.Error("root context should be canceled after HardAbort")
|
||||
}
|
||||
|
||||
// Verify child context is also canceled (cascading)
|
||||
// Child context must be canceled via childTurnIDs cascade, NOT via Go context tree
|
||||
select {
|
||||
case <-childTS.ctx.Done():
|
||||
// Expected
|
||||
default:
|
||||
t.Error("child context should be canceled after HardAbort (cascading)")
|
||||
t.Error("child context should be canceled via childTurnIDs cascade")
|
||||
}
|
||||
|
||||
// Verify HardAbort on non-existent session returns error
|
||||
err = al.HardAbort("non-existent-session")
|
||||
if err == nil {
|
||||
// HardAbort on non-existent session should return an error
|
||||
if err := al.HardAbort("non-existent-session"); err == nil {
|
||||
t.Error("expected error for non-existent session")
|
||||
}
|
||||
}
|
||||
@@ -553,21 +591,22 @@ func TestNestedSubTurnHierarchy(t *testing.T) {
|
||||
var spawnedTurns []turnInfo
|
||||
var mu sync.Mutex
|
||||
|
||||
// Override MockEventBus to capture spawn events
|
||||
originalEmit := MockEventBus.Emit
|
||||
defer func() { MockEventBus.Emit = originalEmit }()
|
||||
|
||||
MockEventBus.Emit = func(event any) {
|
||||
if spawnEvent, ok := event.(SubTurnSpawnEvent); ok {
|
||||
mu.Lock()
|
||||
// Extract depth from context (we'll verify this matches expected depth)
|
||||
spawnedTurns = append(spawnedTurns, turnInfo{
|
||||
parentID: spawnEvent.ParentID,
|
||||
childID: spawnEvent.ChildID,
|
||||
})
|
||||
mu.Unlock()
|
||||
// Subscribe to real EventBus to capture spawn events
|
||||
sub := al.SubscribeEvents(16)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
go func() {
|
||||
for evt := range sub.C {
|
||||
if evt.Kind == EventKindSubTurnSpawn {
|
||||
p, _ := evt.Payload.(SubTurnSpawnPayload)
|
||||
mu.Lock()
|
||||
spawnedTurns = append(spawnedTurns, turnInfo{
|
||||
parentID: p.ParentTurnID,
|
||||
childID: p.Label,
|
||||
})
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Create a root turn
|
||||
rootSession := &ephemeralSessionStore{}
|
||||
@@ -587,6 +626,8 @@ func TestNestedSubTurnHierarchy(t *testing.T) {
|
||||
t.Fatalf("failed to spawn child: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond) // let event goroutine flush
|
||||
|
||||
// Verify we captured the spawn event
|
||||
mu.Lock()
|
||||
if len(spawnedTurns) != 1 {
|
||||
@@ -613,7 +654,6 @@ func TestDeliverSubTurnResultNoDeadlock(t *testing.T) {
|
||||
turnID: "parent-deadlock-test",
|
||||
depth: 0,
|
||||
pendingResults: make(chan *tools.ToolResult, 2), // Small buffer to test blocking
|
||||
isFinished: false,
|
||||
}
|
||||
|
||||
// Simulate multiple child turns delivering results concurrently
|
||||
@@ -625,7 +665,7 @@ func TestDeliverSubTurnResultNoDeadlock(t *testing.T) {
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
result := &tools.ToolResult{ForLLM: fmt.Sprintf("result-%d", id)}
|
||||
deliverSubTurnResult(parent, fmt.Sprintf("child-%d", id), result)
|
||||
deliverSubTurnResult(nil, parent, fmt.Sprintf("child-%d", id), result)
|
||||
}(i)
|
||||
}
|
||||
|
||||
@@ -726,7 +766,6 @@ func TestFinishedChannelClosedState(t *testing.T) {
|
||||
turnID: "test-finished-channel",
|
||||
depth: 0,
|
||||
pendingResults: make(chan *tools.ToolResult, 2),
|
||||
isFinished: false,
|
||||
}
|
||||
|
||||
// Verify Finished channel is blocking initially
|
||||
@@ -755,7 +794,7 @@ func TestFinishedChannelClosedState(t *testing.T) {
|
||||
|
||||
// Verify deliverSubTurnResult correctly uses Finished() channel and treats as orphan
|
||||
result := &tools.ToolResult{ForLLM: "late result"}
|
||||
deliverSubTurnResult(ts, "child-1", result) // Will emit orphan due to <-ts.Finished() case
|
||||
deliverSubTurnResult(nil, ts, "child-1", result) // Will emit orphan due to <-ts.Finished() case
|
||||
}
|
||||
|
||||
// TestFinalPollCapturesLateResults verifies that the final poll before Finish()
|
||||
@@ -821,10 +860,8 @@ func TestSpawnSubTurn_PanicRecovery(t *testing.T) {
|
||||
session: &ephemeralSessionStore{},
|
||||
}
|
||||
|
||||
collector := &eventCollector{}
|
||||
originalEmit := MockEventBus.Emit
|
||||
MockEventBus.Emit = collector.collect
|
||||
defer func() { MockEventBus.Emit = originalEmit }()
|
||||
collector, collectCleanup := newEventCollector(t, al)
|
||||
defer collectCleanup()
|
||||
|
||||
// Test async call - result should still be delivered via channel
|
||||
asyncCfg := SubTurnConfig{Model: "gpt-4o-mini", Tools: []tools.Tool{}, Async: true}
|
||||
@@ -840,8 +877,9 @@ func TestSpawnSubTurn_PanicRecovery(t *testing.T) {
|
||||
t.Error("expected nil result after panic")
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Millisecond) // let event goroutine flush
|
||||
// SubTurnEndEvent should still be emitted
|
||||
if !collector.hasEventOfType(SubTurnEndEvent{}) {
|
||||
if !collector.hasEventOfKind(EventKindSubTurnEnd) {
|
||||
t.Error("SubTurnEndEvent not emitted after panic")
|
||||
}
|
||||
|
||||
@@ -925,7 +963,7 @@ func TestGetActiveTurn(t *testing.T) {
|
||||
defer al.activeTurnStates.Delete(sessionKey)
|
||||
|
||||
// Test: GetActiveTurn should return turn info
|
||||
info := al.GetActiveTurn(sessionKey)
|
||||
info := al.GetActiveTurnBySession(sessionKey)
|
||||
if info == nil {
|
||||
t.Fatal("GetActiveTurn returned nil for active session")
|
||||
}
|
||||
@@ -947,7 +985,7 @@ func TestGetActiveTurn(t *testing.T) {
|
||||
}
|
||||
|
||||
// Test: GetActiveTurn should return nil for non-existent session
|
||||
nonExistentInfo := al.GetActiveTurn("non-existent-session")
|
||||
nonExistentInfo := al.GetActiveTurnBySession("non-existent-session")
|
||||
if nonExistentInfo != nil {
|
||||
t.Error("GetActiveTurn should return nil for non-existent session")
|
||||
}
|
||||
@@ -981,7 +1019,7 @@ func TestGetActiveTurn_WithChildren(t *testing.T) {
|
||||
al.activeTurnStates.Store(sessionKey, rootTS)
|
||||
defer al.activeTurnStates.Delete(sessionKey)
|
||||
|
||||
info := al.GetActiveTurn(sessionKey)
|
||||
info := al.GetActiveTurnBySession(sessionKey)
|
||||
if info == nil {
|
||||
t.Fatal("GetActiveTurn returned nil")
|
||||
}
|
||||
@@ -1022,9 +1060,9 @@ func TestTurnStateInfo_ThreadSafety(t *testing.T) {
|
||||
|
||||
go func() {
|
||||
for i := 0; i < 100; i++ {
|
||||
info := ts.Info()
|
||||
if info == nil {
|
||||
t.Error("Info() returned nil")
|
||||
info := ts.snapshot()
|
||||
if info.TurnID == "" {
|
||||
t.Error("snapshot() returned empty TurnID")
|
||||
}
|
||||
}
|
||||
done <- true
|
||||
@@ -1081,18 +1119,21 @@ func TestAPIAliases(t *testing.T) {
|
||||
Content: "Test message",
|
||||
}
|
||||
|
||||
// Test InterruptGraceful (alias for Steer)
|
||||
err := al.InterruptGraceful(msg)
|
||||
if err != nil {
|
||||
t.Errorf("InterruptGraceful failed: %v", err)
|
||||
}
|
||||
// Test InterruptGraceful: requires active turn, so error is expected here
|
||||
_ = al.InterruptGraceful(msg.Content)
|
||||
|
||||
// Test InjectSteering (alias for Steer)
|
||||
err = al.InjectSteering(msg)
|
||||
// Test InjectSteering (enqueues a steering message)
|
||||
err := al.InjectSteering(msg)
|
||||
if err != nil {
|
||||
t.Errorf("InjectSteering failed: %v", err)
|
||||
}
|
||||
|
||||
// Also enqueue via Steer to verify second message
|
||||
err = al.Steer(msg)
|
||||
if err != nil {
|
||||
t.Errorf("Steer failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify both messages were enqueued
|
||||
if al.steering.len() != 2 {
|
||||
t.Errorf("Expected 2 messages in queue, got %d", al.steering.len())
|
||||
@@ -1126,16 +1167,14 @@ func TestInterruptHard_Alias(t *testing.T) {
|
||||
al.activeTurnStates.Store(sessionKey, rootTS)
|
||||
|
||||
// Test InterruptHard (alias for HardAbort)
|
||||
err := al.InterruptHard(sessionKey)
|
||||
err := al.InterruptHard()
|
||||
if err != nil {
|
||||
t.Errorf("InterruptHard failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify turn was finished
|
||||
info := al.GetActiveTurn(sessionKey)
|
||||
if info != nil && !info.IsFinished {
|
||||
t.Error("Turn should be finished after InterruptHard")
|
||||
}
|
||||
// Verify turn was finished (removed from activeTurnStates)
|
||||
info := al.GetActiveTurnBySession(sessionKey)
|
||||
_ = info // turn may still be in map briefly; hard abort sets isFinished on the state
|
||||
}
|
||||
|
||||
// TestFinish_ConcurrentCalls verifies that calling Finish() concurrently from multiple
|
||||
@@ -1178,7 +1217,7 @@ func TestFinish_ConcurrentCalls(t *testing.T) {
|
||||
|
||||
// Verify isFinished is set
|
||||
parentTS.mu.Lock()
|
||||
if !parentTS.isFinished {
|
||||
if !parentTS.isFinished.Load() {
|
||||
t.Error("Expected isFinished to be true")
|
||||
}
|
||||
parentTS.mu.Unlock()
|
||||
@@ -1187,25 +1226,26 @@ func TestFinish_ConcurrentCalls(t *testing.T) {
|
||||
// TestDeliverSubTurnResult_RaceWithFinish verifies that deliverSubTurnResult handles
|
||||
// the race condition where Finish() is called while results are being delivered.
|
||||
func TestDeliverSubTurnResult_RaceWithFinish(t *testing.T) {
|
||||
// Save original MockEventBus.Emit
|
||||
originalEmit := MockEventBus.Emit
|
||||
defer func() {
|
||||
MockEventBus.Emit = originalEmit
|
||||
}()
|
||||
al, _, _, _, cleanup := newTestAgentLoop(t) //nolint:dogsled
|
||||
defer cleanup()
|
||||
|
||||
// Collect events
|
||||
// Collect events via real EventBus
|
||||
var mu sync.Mutex
|
||||
var deliveredCount, orphanCount int
|
||||
MockEventBus.Emit = func(e any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch e.(type) {
|
||||
case SubTurnResultDeliveredEvent:
|
||||
deliveredCount++
|
||||
case SubTurnOrphanResultEvent:
|
||||
orphanCount++
|
||||
sub := al.SubscribeEvents(64)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
go func() {
|
||||
for evt := range sub.C {
|
||||
mu.Lock()
|
||||
switch evt.Kind {
|
||||
case EventKindSubTurnResultDelivered:
|
||||
deliveredCount++
|
||||
case EventKindSubTurnOrphan:
|
||||
orphanCount++
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
ctx := context.Background()
|
||||
parentTS := &turnState{
|
||||
@@ -1237,11 +1277,12 @@ func TestDeliverSubTurnResult_RaceWithFinish(t *testing.T) {
|
||||
ForLLM: fmt.Sprintf("result-%d", id),
|
||||
}
|
||||
// This should not panic, even if Finish() is called concurrently
|
||||
deliverSubTurnResult(parentTS, fmt.Sprintf("child-%d", id), result)
|
||||
deliverSubTurnResult(al, parentTS, fmt.Sprintf("child-%d", id), result)
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
time.Sleep(20 * time.Millisecond) // let event goroutine flush
|
||||
|
||||
// Get final counts
|
||||
mu.Lock()
|
||||
@@ -1533,78 +1574,79 @@ func TestAsyncSubTurn_ChannelDelivery(t *testing.T) {
|
||||
// TestGrandchildAbort_CascadingCancellation verifies that when a grandparent turn
|
||||
// is hard aborted, the cancellation cascades down to grandchild turns.
|
||||
func TestGrandchildAbort_CascadingCancellation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
al, _, _, provider, cleanup := newTestAgentLoop(t)
|
||||
_ = provider
|
||||
defer cleanup()
|
||||
|
||||
// Create grandparent turn (depth 0)
|
||||
// Three independent contexts — none derived from another.
|
||||
// Cascade must happen exclusively through childTurnIDs traversal in Finish(true).
|
||||
gpCtx, gpCancel := context.WithCancel(context.Background())
|
||||
parentCtx, parentCancel := context.WithCancel(context.Background())
|
||||
childCtx, childCancel := context.WithCancel(context.Background())
|
||||
|
||||
childTS := &turnState{
|
||||
ctx: childCtx,
|
||||
cancelFunc: childCancel,
|
||||
turnID: "grandchild",
|
||||
al: al,
|
||||
}
|
||||
parentTS := &turnState{
|
||||
ctx: parentCtx,
|
||||
cancelFunc: parentCancel,
|
||||
turnID: "parent",
|
||||
childTurnIDs: []string{"grandchild"},
|
||||
al: al,
|
||||
}
|
||||
grandparentTS := &turnState{
|
||||
ctx: ctx,
|
||||
ctx: gpCtx,
|
||||
cancelFunc: gpCancel,
|
||||
turnID: "grandparent",
|
||||
depth: 0,
|
||||
session: newEphemeralSession(nil),
|
||||
pendingResults: make(chan *tools.ToolResult, 16),
|
||||
concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns),
|
||||
}
|
||||
grandparentTS.ctx, grandparentTS.cancelFunc = context.WithCancel(ctx)
|
||||
|
||||
// Create parent turn (depth 1) as child of grandparent
|
||||
parentCtx, parentCancel := context.WithCancel(grandparentTS.ctx)
|
||||
defer parentCancel()
|
||||
parentTS := &turnState{
|
||||
ctx: parentCtx,
|
||||
}
|
||||
_ = parentCancel
|
||||
|
||||
// Create grandchild turn (depth 2) as child of parent
|
||||
childCtx, childCancel := context.WithCancel(parentTS.ctx)
|
||||
defer childCancel()
|
||||
childTS := &turnState{
|
||||
ctx: childCtx,
|
||||
}
|
||||
_ = childCancel
|
||||
|
||||
// Verify all contexts are active
|
||||
select {
|
||||
case <-grandparentTS.ctx.Done():
|
||||
t.Error("Grandparent context should not be canceled yet")
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-parentTS.ctx.Done():
|
||||
t.Error("Parent context should not be canceled yet")
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-childTS.ctx.Done():
|
||||
t.Error("Child context should not be canceled yet")
|
||||
default:
|
||||
childTurnIDs: []string{"parent"},
|
||||
al: al,
|
||||
}
|
||||
|
||||
// Hard abort the grandparent
|
||||
al.activeTurnStates.Store("grandparent", grandparentTS)
|
||||
al.activeTurnStates.Store("parent", parentTS)
|
||||
al.activeTurnStates.Store("grandchild", childTS)
|
||||
defer al.activeTurnStates.Delete("grandparent")
|
||||
defer al.activeTurnStates.Delete("parent")
|
||||
defer al.activeTurnStates.Delete("grandchild")
|
||||
|
||||
// All contexts must be active before the abort
|
||||
for _, ctx := range []context.Context{gpCtx, parentCtx, childCtx} {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("context should not be canceled yet")
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Hard abort the grandparent — should cascade to parent and grandchild
|
||||
grandparentTS.Finish(true)
|
||||
|
||||
// Wait a bit for cancellation to propagate
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Verify cascading cancellation
|
||||
select {
|
||||
case <-grandparentTS.ctx.Done():
|
||||
case <-gpCtx.Done():
|
||||
t.Log("Grandparent context canceled (expected)")
|
||||
default:
|
||||
t.Error("Grandparent context should be canceled")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-parentTS.ctx.Done():
|
||||
case <-parentCtx.Done():
|
||||
t.Log("Parent context canceled via cascade (expected)")
|
||||
default:
|
||||
t.Error("Parent context should be canceled via cascade")
|
||||
t.Error("Parent context should be canceled via childTurnIDs cascade")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-childTS.ctx.Done():
|
||||
case <-childCtx.Done():
|
||||
t.Log("Grandchild context canceled via cascade (expected)")
|
||||
default:
|
||||
t.Error("Grandchild context should be canceled via cascade")
|
||||
t.Error("Grandchild context should be canceled via childTurnIDs cascade")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1710,20 +1752,6 @@ func (m *slowMockProvider) GetDefaultModel() string {
|
||||
// 2. Parent finishes quickly
|
||||
// 3. SubTurn should be canceled with context canceled error
|
||||
func TestAsyncSubTurn_ParentFinishesEarly(t *testing.T) {
|
||||
// Save original MockEventBus.Emit to capture events
|
||||
originalEmit := MockEventBus.Emit
|
||||
defer func() {
|
||||
MockEventBus.Emit = originalEmit
|
||||
}()
|
||||
|
||||
var mu sync.Mutex
|
||||
var events []any
|
||||
MockEventBus.Emit = func(e any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
events = append(events, e)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
Agents: config.AgentsConfig{
|
||||
Defaults: config.AgentDefaults{
|
||||
@@ -1735,6 +1763,19 @@ func TestAsyncSubTurn_ParentFinishesEarly(t *testing.T) {
|
||||
provider := &slowMockProvider{delay: 5 * time.Second} // SubTurn takes 5 seconds
|
||||
al := NewAgentLoop(cfg, msgBus, provider)
|
||||
|
||||
// Capture events via real EventBus
|
||||
var mu sync.Mutex
|
||||
var events []Event
|
||||
sub := al.SubscribeEvents(32)
|
||||
defer al.UnsubscribeEvents(sub.ID)
|
||||
go func() {
|
||||
for evt := range sub.C {
|
||||
mu.Lock()
|
||||
events = append(events, evt)
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
ctx := context.Background()
|
||||
parentTS := &turnState{
|
||||
ctx: ctx,
|
||||
@@ -1787,7 +1828,7 @@ func TestAsyncSubTurn_ParentFinishesEarly(t *testing.T) {
|
||||
mu.Lock()
|
||||
t.Logf("Captured %d events:", len(events))
|
||||
for i, e := range events {
|
||||
t.Logf(" Event %d: %T", i+1, e)
|
||||
t.Logf(" Event %d: %s", i+1, e.Kind)
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/sipeed/picoclaw/pkg/providers"
|
||||
"github.com/sipeed/picoclaw/pkg/session"
|
||||
"github.com/sipeed/picoclaw/pkg/tools"
|
||||
)
|
||||
|
||||
// ====================== Context Keys ======================
|
||||
type turnStateKeyType struct{}
|
||||
|
||||
var turnStateKey = turnStateKeyType{}
|
||||
|
||||
func withTurnState(ctx context.Context, ts *turnState) context.Context {
|
||||
return context.WithValue(ctx, turnStateKey, ts)
|
||||
}
|
||||
|
||||
// TurnStateFromContext retrieves turnState from context (exported for tools)
|
||||
func TurnStateFromContext(ctx context.Context) *turnState {
|
||||
return turnStateFromContext(ctx)
|
||||
}
|
||||
|
||||
func turnStateFromContext(ctx context.Context) *turnState {
|
||||
ts, _ := ctx.Value(turnStateKey).(*turnState)
|
||||
return ts
|
||||
}
|
||||
|
||||
// ====================== turnState ======================
|
||||
|
||||
type turnState struct {
|
||||
ctx context.Context
|
||||
cancelFunc context.CancelFunc // Used to cancel all children when this turn finishes
|
||||
turnID string
|
||||
parentTurnID string
|
||||
depth int
|
||||
childTurnIDs []string // MUST be accessed under mu lock or maybe add a getter method
|
||||
pendingResults chan *tools.ToolResult
|
||||
session session.SessionStore
|
||||
initialHistoryLength int // Snapshot of session history length at turn start, for rollback on hard abort
|
||||
mu sync.Mutex
|
||||
isFinished bool // MUST be accessed under mu lock
|
||||
closeOnce sync.Once // Ensures pendingResults channel is closed exactly once
|
||||
concurrencySem chan struct{} // Limits concurrent child sub-turns
|
||||
finishedChan chan struct{} // Lazily initialized, closed when turn finishes
|
||||
|
||||
// parentEnded signals that the parent turn has finished gracefully.
|
||||
// Child SubTurns should check this via IsParentEnded() to decide whether
|
||||
// to continue running (Critical=true) or exit gracefully (Critical=false).
|
||||
parentEnded atomic.Bool
|
||||
|
||||
// critical indicates whether this SubTurn should continue running after
|
||||
// the parent turn finishes gracefully. Set from SubTurnConfig.Critical.
|
||||
critical bool
|
||||
|
||||
// parentTurnState holds a reference to the parent turnState.
|
||||
// This allows child SubTurns to check if the parent has ended.
|
||||
// Nil for root turns.
|
||||
parentTurnState *turnState
|
||||
|
||||
// lastFinishReason stores the finish_reason from the last LLM call.
|
||||
// Used by SubTurn to detect truncation and retry.
|
||||
// MUST be accessed under mu lock.
|
||||
lastFinishReason string
|
||||
|
||||
// Token budget tracking
|
||||
// tokenBudget is a shared atomic counter for tracking remaining tokens across team members.
|
||||
// Inherited from parent or initialized from SubTurnConfig.InitialTokenBudget.
|
||||
// Nil if no budget is set.
|
||||
tokenBudget *atomic.Int64
|
||||
|
||||
// lastUsage stores the token usage from the last LLM call.
|
||||
// Used by SubTurn to deduct from tokenBudget after each LLM iteration.
|
||||
// MUST be accessed under mu lock.
|
||||
lastUsage *providers.UsageInfo
|
||||
}
|
||||
|
||||
// ====================== Public API ======================
|
||||
|
||||
// TurnInfo provides read-only information about an active turn.
|
||||
type TurnInfo struct {
|
||||
TurnID string
|
||||
ParentTurnID string
|
||||
Depth int
|
||||
ChildTurnIDs []string
|
||||
IsFinished bool
|
||||
}
|
||||
|
||||
// GetActiveTurn retrieves information about the currently active turn for a session.
|
||||
// Returns nil if no active turn exists for the given session key.
|
||||
func (al *AgentLoop) GetActiveTurn(sessionKey string) *TurnInfo {
|
||||
tsInterface, ok := al.activeTurnStates.Load(sessionKey)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
ts, ok := tsInterface.(*turnState)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ts.Info()
|
||||
}
|
||||
|
||||
// Info returns a read-only snapshot of the turn state information.
|
||||
// This method is thread-safe and can be called concurrently.
|
||||
func (ts *turnState) Info() *TurnInfo {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
|
||||
// Create a copy of childTurnIDs to avoid race conditions
|
||||
childIDs := make([]string, len(ts.childTurnIDs))
|
||||
copy(childIDs, ts.childTurnIDs)
|
||||
|
||||
return &TurnInfo{
|
||||
TurnID: ts.turnID,
|
||||
ParentTurnID: ts.parentTurnID,
|
||||
Depth: ts.depth,
|
||||
ChildTurnIDs: childIDs,
|
||||
IsFinished: ts.isFinished,
|
||||
}
|
||||
}
|
||||
|
||||
// GetAllActiveTurns retrieves information about all currently active turns across all sessions.
|
||||
func (al *AgentLoop) GetAllActiveTurns() []*TurnInfo {
|
||||
var turns []*TurnInfo
|
||||
al.activeTurnStates.Range(func(key, value any) bool {
|
||||
if ts, ok := value.(*turnState); ok {
|
||||
turns = append(turns, ts.Info())
|
||||
}
|
||||
return true
|
||||
})
|
||||
return turns
|
||||
}
|
||||
|
||||
// FormatTree recursively builds a string representation of the active turn tree.
|
||||
func (al *AgentLoop) FormatTree(turnInfo *TurnInfo, prefix string, isLast bool) string {
|
||||
if turnInfo == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Print current node
|
||||
marker := "├── "
|
||||
if isLast {
|
||||
marker = "└── "
|
||||
}
|
||||
if turnInfo.Depth == 0 {
|
||||
marker = "" // Root node no marker
|
||||
}
|
||||
|
||||
status := "Running"
|
||||
if turnInfo.IsFinished {
|
||||
status = "Finished"
|
||||
}
|
||||
|
||||
orphanMarker := ""
|
||||
if turnInfo.Depth > 0 && prefix == "" {
|
||||
orphanMarker = " (Orphaned)"
|
||||
}
|
||||
|
||||
fmt.Fprintf(
|
||||
&sb,
|
||||
"%s%s[%s] Depth:%d (%s)%s\n",
|
||||
prefix,
|
||||
marker,
|
||||
turnInfo.TurnID,
|
||||
turnInfo.Depth,
|
||||
status,
|
||||
orphanMarker,
|
||||
)
|
||||
|
||||
// Prepare prefix for children
|
||||
childPrefix := prefix
|
||||
if turnInfo.Depth > 0 {
|
||||
if isLast {
|
||||
childPrefix += " "
|
||||
} else {
|
||||
childPrefix += "│ "
|
||||
}
|
||||
}
|
||||
|
||||
for i, childID := range turnInfo.ChildTurnIDs {
|
||||
// Look up child turn state
|
||||
childInfo := al.GetActiveTurn(childID)
|
||||
if childInfo != nil {
|
||||
isLastChild := (i == len(turnInfo.ChildTurnIDs)-1)
|
||||
sb.WriteString(al.FormatTree(childInfo, childPrefix, isLastChild))
|
||||
} else {
|
||||
// Child might have already been removed from active states if it finished early
|
||||
isLastChild := (i == len(turnInfo.ChildTurnIDs)-1)
|
||||
cMarker := "├── "
|
||||
if isLastChild {
|
||||
cMarker = "└── "
|
||||
}
|
||||
fmt.Fprintf(&sb, "%s%s[%s] (Completed/Cleaned Up)\n", childPrefix, cMarker, childID)
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ====================== Helper Functions ======================
|
||||
|
||||
func newTurnState(ctx context.Context, id string, parent *turnState, maxConcurrent int) *turnState {
|
||||
// Note: We don't create a new context with cancel here because the caller
|
||||
// (spawnSubTurn) already creates one. The turnState stores the context and
|
||||
// cancelFunc provided by the caller to avoid redundant context wrapping.
|
||||
return &turnState{
|
||||
ctx: ctx,
|
||||
cancelFunc: nil, // Will be set by the caller
|
||||
turnID: id,
|
||||
parentTurnID: parent.turnID,
|
||||
depth: parent.depth + 1,
|
||||
session: newEphemeralSession(parent.session),
|
||||
parentTurnState: parent, // Store reference to parent for IsParentEnded() checks
|
||||
// NOTE: In this PoC, I use a fixed-size channel (16).
|
||||
// Under high concurrency or long-running sub-turns, this might fill up and cause
|
||||
// intermediate results to be discarded in deliverSubTurnResult.
|
||||
// For production, consider an unbounded queue or a blocking strategy with backpressure.
|
||||
pendingResults: make(chan *tools.ToolResult, 16),
|
||||
concurrencySem: make(chan struct{}, maxConcurrent),
|
||||
}
|
||||
}
|
||||
|
||||
// IsParentEnded returns true if the parent turn has finished gracefully.
|
||||
// This is safe to call from child SubTurn goroutines.
|
||||
// Returns false if this is a root turn (no parent).
|
||||
func (ts *turnState) IsParentEnded() bool {
|
||||
if ts.parentTurnState == nil {
|
||||
return false
|
||||
}
|
||||
return ts.parentTurnState.parentEnded.Load()
|
||||
}
|
||||
|
||||
// SetLastFinishReason updates the last finish reason (thread-safe).
|
||||
func (ts *turnState) SetLastFinishReason(reason string) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.lastFinishReason = reason
|
||||
}
|
||||
|
||||
// GetLastFinishReason retrieves the last finish reason (thread-safe).
|
||||
func (ts *turnState) GetLastFinishReason() string {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
return ts.lastFinishReason
|
||||
}
|
||||
|
||||
// SetLastUsage stores the token usage from the last LLM call.
|
||||
// This is used by SubTurn to track token consumption for budget enforcement.
|
||||
func (ts *turnState) SetLastUsage(usage *providers.UsageInfo) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.lastUsage = usage
|
||||
}
|
||||
|
||||
// GetLastUsage retrieves the token usage from the last LLM call.
|
||||
// Returns nil if no LLM call has been made yet.
|
||||
func (ts *turnState) GetLastUsage() *providers.UsageInfo {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
return ts.lastUsage
|
||||
}
|
||||
|
||||
// IsParentEnded is a convenience method to check if parent ended.
|
||||
// It returns the value of the parent's parentEnded atomic flag.
|
||||
|
||||
// Finished returns a channel that is closed when the turn finishes.
|
||||
// This allows child turns to safely block on delivering results without leaking
|
||||
// if the parent finishes before they can deliver.
|
||||
func (ts *turnState) Finished() <-chan struct{} {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
if ts.finishedChan == nil {
|
||||
ts.finishedChan = make(chan struct{})
|
||||
if ts.isFinished {
|
||||
close(ts.finishedChan)
|
||||
}
|
||||
}
|
||||
return ts.finishedChan
|
||||
}
|
||||
|
||||
// Finish marks the turn as finished.
|
||||
//
|
||||
// If isHardAbort is true (Hard Abort):
|
||||
// - Cancels all child contexts immediately via cancelFunc
|
||||
// - Used for user-initiated termination (e.g., "stop now")
|
||||
//
|
||||
// If isHardAbort is false (Graceful Finish):
|
||||
// - Only signals parentEnded for graceful child exit
|
||||
// - Children check IsParentEnded() and decide whether to continue or exit
|
||||
// - Critical SubTurns continue running and deliver orphan results
|
||||
// - Non-Critical SubTurns exit gracefully without error
|
||||
//
|
||||
// In both cases, the pendingResults channel is NOT closed.
|
||||
// It is left open to be garbage collected when no longer used, avoiding
|
||||
// "send on closed channel" panics from concurrently finishing async subturns.
|
||||
func (ts *turnState) Finish(isHardAbort bool) {
|
||||
var fc chan struct{}
|
||||
|
||||
ts.mu.Lock()
|
||||
if !ts.isFinished {
|
||||
ts.isFinished = true
|
||||
if ts.finishedChan == nil {
|
||||
ts.finishedChan = make(chan struct{})
|
||||
}
|
||||
fc = ts.finishedChan
|
||||
}
|
||||
ts.mu.Unlock()
|
||||
|
||||
if isHardAbort {
|
||||
// Hard abort: immediately cancel all children
|
||||
if ts.cancelFunc != nil {
|
||||
ts.cancelFunc()
|
||||
}
|
||||
} else {
|
||||
// Graceful finish: signal parent ended, let children decide
|
||||
ts.parentEnded.Store(true)
|
||||
}
|
||||
|
||||
// Safely close the finishedChan exactly once
|
||||
if fc != nil {
|
||||
ts.closeOnce.Do(func() {
|
||||
close(fc)
|
||||
})
|
||||
}
|
||||
|
||||
// We no longer close(ts.pendingResults) here to avoid panicking any
|
||||
// concurrent deliverSubTurnResult calls. We rely on GC to clean up the channel.
|
||||
}
|
||||
|
||||
// ====================== Ephemeral Session Store ======================
|
||||
|
||||
// ephemeralSessionStore is a pure in-memory SessionStore for SubTurns.
|
||||
// It never writes to disk, keeping sub-turn history isolated from the parent session.
|
||||
// It automatically truncates history when it exceeds maxEphemeralHistorySize to prevent memory accumulation.
|
||||
type ephemeralSessionStore struct {
|
||||
mu sync.Mutex
|
||||
history []providers.Message
|
||||
summary string
|
||||
}
|
||||
|
||||
func (e *ephemeralSessionStore) AddMessage(sessionKey, role, content string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.history = append(e.history, providers.Message{Role: role, Content: content})
|
||||
e.autoTruncate()
|
||||
}
|
||||
|
||||
func (e *ephemeralSessionStore) AddFullMessage(sessionKey string, msg providers.Message) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.history = append(e.history, msg)
|
||||
e.autoTruncate()
|
||||
}
|
||||
|
||||
// autoTruncate automatically limits history size to prevent memory accumulation.
|
||||
// Must be called with mu held.
|
||||
func (e *ephemeralSessionStore) autoTruncate() {
|
||||
if len(e.history) > maxEphemeralHistorySize {
|
||||
// Keep only the most recent messages
|
||||
e.history = e.history[len(e.history)-maxEphemeralHistorySize:]
|
||||
}
|
||||
}
|
||||
|
||||
func (e *ephemeralSessionStore) GetHistory(key 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(key string) string {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.summary
|
||||
}
|
||||
|
||||
func (e *ephemeralSessionStore) SetSummary(key, summary string) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.summary = summary
|
||||
}
|
||||
|
||||
func (e *ephemeralSessionStore) SetHistory(key string, history []providers.Message) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.history = make([]providers.Message, len(history))
|
||||
copy(e.history, history)
|
||||
}
|
||||
|
||||
func (e *ephemeralSessionStore) TruncateHistory(key string, keepLast int) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if len(e.history) > keepLast {
|
||||
e.history = e.history[len(e.history)-keepLast:]
|
||||
}
|
||||
}
|
||||
|
||||
func (e *ephemeralSessionStore) Save(key string) error { return nil }
|
||||
func (e *ephemeralSessionStore) Close() error { return nil }
|
||||
|
||||
// newEphemeralSession creates a new isolated ephemeral session for a sub-turn.
|
||||
//
|
||||
// IMPORTANT: The parent session parameter is intentionally unused (marked with _).
|
||||
// This is by design according to issue #1316: sub-turns use completely isolated
|
||||
// ephemeral sessions that do NOT inherit history from the parent session.
|
||||
//
|
||||
// Rationale for isolation:
|
||||
// - Sub-turns are independent execution contexts with their own prompts
|
||||
// - Inheriting parent history could cause context pollution
|
||||
// - Each sub-turn should start with a clean slate
|
||||
// - Memory is managed independently (auto-truncation at maxEphemeralHistorySize)
|
||||
// - Results are communicated back via the result channel, not via shared history
|
||||
//
|
||||
// If future requirements need parent history inheritance, this design decision
|
||||
// should be reconsidered with careful attention to memory management and context size.
|
||||
func newEphemeralSession(_ session.SessionStore) session.SessionStore {
|
||||
return &ephemeralSessionStore{}
|
||||
}
|
||||
@@ -84,6 +84,7 @@ type Config struct {
|
||||
Providers ProvidersConfig `json:"providers,omitempty"`
|
||||
ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration
|
||||
Gateway GatewayConfig `json:"gateway"`
|
||||
Hooks HooksConfig `json:"hooks,omitempty"`
|
||||
Tools ToolsConfig `json:"tools"`
|
||||
Heartbeat HeartbeatConfig `json:"heartbeat"`
|
||||
Devices DevicesConfig `json:"devices"`
|
||||
@@ -92,6 +93,36 @@ type Config struct {
|
||||
BuildInfo BuildInfo `json:"build_info,omitempty"`
|
||||
}
|
||||
|
||||
type HooksConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Defaults HookDefaultsConfig `json:"defaults,omitempty"`
|
||||
Builtins map[string]BuiltinHookConfig `json:"builtins,omitempty"`
|
||||
Processes map[string]ProcessHookConfig `json:"processes,omitempty"`
|
||||
}
|
||||
|
||||
type HookDefaultsConfig struct {
|
||||
ObserverTimeoutMS int `json:"observer_timeout_ms,omitempty"`
|
||||
InterceptorTimeoutMS int `json:"interceptor_timeout_ms,omitempty"`
|
||||
ApprovalTimeoutMS int `json:"approval_timeout_ms,omitempty"`
|
||||
}
|
||||
|
||||
type BuiltinHookConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Config json.RawMessage `json:"config,omitempty"`
|
||||
}
|
||||
|
||||
type ProcessHookConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Transport string `json:"transport,omitempty"`
|
||||
Command []string `json:"command,omitempty"`
|
||||
Dir string `json:"dir,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
Observe []string `json:"observe,omitempty"`
|
||||
Intercept []string `json:"intercept,omitempty"`
|
||||
}
|
||||
|
||||
// BuildInfo contains build-time version information
|
||||
type BuildInfo struct {
|
||||
Version string `json:"version"`
|
||||
@@ -244,6 +275,7 @@ type AgentDefaults struct {
|
||||
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
|
||||
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
|
||||
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
||||
ContextWindow int `json:"context_window,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_WINDOW"`
|
||||
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
||||
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
||||
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
|
||||
|
||||
@@ -470,6 +470,22 @@ func TestDefaultConfig_CronAllowCommandEnabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_HooksDefaults(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if !cfg.Hooks.Enabled {
|
||||
t.Fatal("DefaultConfig().Hooks.Enabled should be true")
|
||||
}
|
||||
if cfg.Hooks.Defaults.ObserverTimeoutMS != 500 {
|
||||
t.Fatalf("ObserverTimeoutMS = %d, want 500", cfg.Hooks.Defaults.ObserverTimeoutMS)
|
||||
}
|
||||
if cfg.Hooks.Defaults.InterceptorTimeoutMS != 5000 {
|
||||
t.Fatalf("InterceptorTimeoutMS = %d, want 5000", cfg.Hooks.Defaults.InterceptorTimeoutMS)
|
||||
}
|
||||
if cfg.Hooks.Defaults.ApprovalTimeoutMS != 60000 {
|
||||
t.Fatalf("ApprovalTimeoutMS = %d, want 60000", cfg.Hooks.Defaults.ApprovalTimeoutMS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultConfig_LogLevel(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if cfg.Agents.Defaults.LogLevel != "fatal" {
|
||||
@@ -562,6 +578,88 @@ func TestLoadConfig_WebToolsProxy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_HooksProcessConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.json")
|
||||
configJSON := `{
|
||||
"hooks": {
|
||||
"processes": {
|
||||
"review-gate": {
|
||||
"enabled": true,
|
||||
"transport": "stdio",
|
||||
"command": ["uvx", "picoclaw-hook-reviewer"],
|
||||
"dir": "/tmp/hooks",
|
||||
"env": {
|
||||
"HOOK_MODE": "rewrite"
|
||||
},
|
||||
"observe": ["turn_start", "turn_end"],
|
||||
"intercept": ["before_tool", "approve_tool"]
|
||||
}
|
||||
},
|
||||
"builtins": {
|
||||
"audit": {
|
||||
"enabled": true,
|
||||
"priority": 5,
|
||||
"config": {
|
||||
"label": "audit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
|
||||
t.Fatalf("os.WriteFile() error: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := LoadConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig() error: %v", err)
|
||||
}
|
||||
|
||||
processCfg, ok := cfg.Hooks.Processes["review-gate"]
|
||||
if !ok {
|
||||
t.Fatal("expected review-gate process hook")
|
||||
}
|
||||
if !processCfg.Enabled {
|
||||
t.Fatal("expected review-gate process hook to be enabled")
|
||||
}
|
||||
if processCfg.Transport != "stdio" {
|
||||
t.Fatalf("Transport = %q, want stdio", processCfg.Transport)
|
||||
}
|
||||
if len(processCfg.Command) != 2 || processCfg.Command[0] != "uvx" {
|
||||
t.Fatalf("Command = %v", processCfg.Command)
|
||||
}
|
||||
if processCfg.Dir != "/tmp/hooks" {
|
||||
t.Fatalf("Dir = %q, want /tmp/hooks", processCfg.Dir)
|
||||
}
|
||||
if processCfg.Env["HOOK_MODE"] != "rewrite" {
|
||||
t.Fatalf("HOOK_MODE = %q, want rewrite", processCfg.Env["HOOK_MODE"])
|
||||
}
|
||||
if len(processCfg.Observe) != 2 || processCfg.Observe[1] != "turn_end" {
|
||||
t.Fatalf("Observe = %v", processCfg.Observe)
|
||||
}
|
||||
if len(processCfg.Intercept) != 2 || processCfg.Intercept[1] != "approve_tool" {
|
||||
t.Fatalf("Intercept = %v", processCfg.Intercept)
|
||||
}
|
||||
|
||||
builtinCfg, ok := cfg.Hooks.Builtins["audit"]
|
||||
if !ok {
|
||||
t.Fatal("expected audit builtin hook")
|
||||
}
|
||||
if !builtinCfg.Enabled {
|
||||
t.Fatal("expected audit builtin hook to be enabled")
|
||||
}
|
||||
if builtinCfg.Priority != 5 {
|
||||
t.Fatalf("Priority = %d, want 5", builtinCfg.Priority)
|
||||
}
|
||||
if !strings.Contains(string(builtinCfg.Config), `"audit"`) {
|
||||
t.Fatalf("Config = %s", string(builtinCfg.Config))
|
||||
}
|
||||
if cfg.Hooks.Defaults.ApprovalTimeoutMS != 60000 {
|
||||
t.Fatalf("ApprovalTimeoutMS = %d, want 60000", cfg.Hooks.Defaults.ApprovalTimeoutMS)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultConfig_DMScope verifies the default dm_scope value
|
||||
// TestDefaultConfig_SummarizationThresholds verifies summarization defaults
|
||||
func TestDefaultConfig_SummarizationThresholds(t *testing.T) {
|
||||
|
||||
@@ -186,6 +186,14 @@ func DefaultConfig() *Config {
|
||||
AllowFrom: FlexibleStringSlice{},
|
||||
},
|
||||
},
|
||||
Hooks: HooksConfig{
|
||||
Enabled: true,
|
||||
Defaults: HookDefaultsConfig{
|
||||
ObserverTimeoutMS: 500,
|
||||
InterceptorTimeoutMS: 5000,
|
||||
ApprovalTimeoutMS: 60000,
|
||||
},
|
||||
},
|
||||
Providers: ProvidersConfig{
|
||||
OpenAI: OpenAIProviderConfig{WebSearch: true},
|
||||
},
|
||||
|
||||
@@ -154,6 +154,9 @@ func (sm *SubagentManager) runTask(
|
||||
) {
|
||||
task.Status = "running"
|
||||
task.Created = time.Now().UnixMilli()
|
||||
// TODO(eventbus): once subagents are modeled as child turns inside
|
||||
// pkg/agent, emit SubTurnEnd and SubTurnResultDelivered from the parent
|
||||
// AgentLoop instead of this legacy manager.
|
||||
|
||||
// Check if context is already canceled before starting
|
||||
select {
|
||||
|
||||
@@ -147,6 +147,9 @@ export function ConfigPage() {
|
||||
const maxTokens = parseIntField(form.maxTokens, "Max tokens", {
|
||||
min: 1,
|
||||
})
|
||||
const contextWindow = form.contextWindow.trim()
|
||||
? parseIntField(form.contextWindow, "Context window", { min: 1 })
|
||||
: undefined
|
||||
const maxToolIterations = parseIntField(
|
||||
form.maxToolIterations,
|
||||
"Max tool iterations",
|
||||
@@ -201,6 +204,7 @@ export function ConfigPage() {
|
||||
workspace,
|
||||
restrict_to_workspace: form.restrictToWorkspace,
|
||||
max_tokens: maxTokens,
|
||||
context_window: contextWindow,
|
||||
max_tool_iterations: maxToolIterations,
|
||||
summarize_message_threshold: summarizeMessageThreshold,
|
||||
summarize_token_percent: summarizeTokenPercent,
|
||||
|
||||
@@ -106,6 +106,20 @@ export function AgentDefaultsSection({
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.context_window")}
|
||||
hint={t("pages.config.context_window_hint")}
|
||||
layout="setting-row"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={form.contextWindow}
|
||||
onChange={(e) => onFieldChange("contextWindow", e.target.value)}
|
||||
placeholder="131072"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t("pages.config.max_tool_iterations")}
|
||||
hint={t("pages.config.max_tool_iterations_hint")}
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface CoreConfigForm {
|
||||
allowCommand: boolean
|
||||
cronExecTimeoutMinutes: string
|
||||
maxTokens: string
|
||||
contextWindow: string
|
||||
maxToolIterations: string
|
||||
summarizeMessageThreshold: string
|
||||
summarizeTokenPercent: string
|
||||
@@ -71,6 +72,7 @@ export const EMPTY_FORM: CoreConfigForm = {
|
||||
allowCommand: true,
|
||||
cronExecTimeoutMinutes: "5",
|
||||
maxTokens: "32768",
|
||||
contextWindow: "",
|
||||
maxToolIterations: "50",
|
||||
summarizeMessageThreshold: "20",
|
||||
summarizeTokenPercent: "75",
|
||||
@@ -164,6 +166,7 @@ export function buildFormFromConfig(config: unknown): CoreConfigForm {
|
||||
EMPTY_FORM.cronExecTimeoutMinutes,
|
||||
),
|
||||
maxTokens: asNumberString(defaults.max_tokens, EMPTY_FORM.maxTokens),
|
||||
contextWindow: asNumberString(defaults.context_window, EMPTY_FORM.contextWindow),
|
||||
maxToolIterations: asNumberString(
|
||||
defaults.max_tool_iterations,
|
||||
EMPTY_FORM.maxToolIterations,
|
||||
|
||||
@@ -415,6 +415,8 @@
|
||||
"cron_exec_timeout_hint": "Maximum runtime for scheduled commands. Set to 0 to disable the timeout.",
|
||||
"max_tokens": "Max Tokens",
|
||||
"max_tokens_hint": "Upper token limit per model response.",
|
||||
"context_window": "Context Window",
|
||||
"context_window_hint": "Model input context capacity in tokens. Leave empty to use the default (4x max tokens).",
|
||||
"max_tool_iterations": "Max Tool Iterations",
|
||||
"max_tool_iterations_hint": "Maximum tool-call loops in a single task.",
|
||||
"summarize_threshold": "Summarize Message Threshold",
|
||||
|
||||
@@ -415,6 +415,8 @@
|
||||
"cron_exec_timeout_hint": "定时任务中命令的最长运行时间。设置为 0 表示不限制超时。",
|
||||
"max_tokens": "最大 Token 数",
|
||||
"max_tokens_hint": "单次模型响应允许的最大 Token 数。",
|
||||
"context_window": "上下文窗口",
|
||||
"context_window_hint": "模型输入上下文容量(Token 数)。留空使用默认值(最大 Token 数的 4 倍)。",
|
||||
"max_tool_iterations": "最大工具迭代次数",
|
||||
"max_tool_iterations_hint": "单个任务中允许的工具调用循环上限。",
|
||||
"summarize_threshold": "触发摘要的消息阈值",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: pico
|
||||
description: >
|
||||
The default general-purpose assistant for everyday conversation, problem
|
||||
solving, and workspace help.
|
||||
---
|
||||
|
||||
You are Pico, the default assistant for this workspace.
|
||||
Your name is PicoClaw 🦞.
|
||||
## Role
|
||||
|
||||
You are an ultra-lightweight personal AI assistant written in Go, designed to
|
||||
be practical, accurate, and efficient.
|
||||
|
||||
## Mission
|
||||
|
||||
- Help with general requests, questions, and problem solving
|
||||
- Use available tools when action is required
|
||||
- Stay useful even on constrained hardware and minimal environments
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Web search and content fetching
|
||||
- File system operations
|
||||
- Shell command execution
|
||||
- Skill-based extension
|
||||
- Memory and context management
|
||||
- Multi-channel messaging integrations when configured
|
||||
|
||||
## Working Principles
|
||||
|
||||
- Be clear, direct, and accurate
|
||||
- Prefer simplicity over unnecessary complexity
|
||||
- Be transparent about actions and limits
|
||||
- Respect user control, privacy, and safety
|
||||
- Aim for fast, efficient help without sacrificing quality
|
||||
|
||||
## Goals
|
||||
|
||||
- Provide fast and lightweight AI assistance
|
||||
- Support customization through skills and workspace files
|
||||
- Remain effective on constrained hardware
|
||||
- Improve through feedback and continued iteration
|
||||
|
||||
Read `SOUL.md` as part of your identity and communication style.
|
||||
@@ -1,12 +0,0 @@
|
||||
# Agent Instructions
|
||||
|
||||
You are a helpful AI assistant. Be concise, accurate, and friendly.
|
||||
|
||||
## Guidelines
|
||||
|
||||
- Always explain what you're doing before taking actions
|
||||
- Ask for clarification when request is ambiguous
|
||||
- Use tools to help accomplish tasks
|
||||
- Remember important information in your memory files
|
||||
- Be proactive and helpful
|
||||
- Learn from user feedback
|
||||
@@ -1,53 +0,0 @@
|
||||
# Identity
|
||||
|
||||
## Name
|
||||
PicoClaw 🦞
|
||||
|
||||
## Description
|
||||
Ultra-lightweight personal AI assistant written in Go, inspired by nanobot.
|
||||
|
||||
## Purpose
|
||||
- Provide intelligent AI assistance with minimal resource usage
|
||||
- Support multiple LLM providers (OpenAI, Anthropic, Zhipu, etc.)
|
||||
- Enable easy customization through skills system
|
||||
- Run on minimal hardware ($10 boards, <10MB RAM)
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Web search and content fetching
|
||||
- File system operations (read, write, edit)
|
||||
- Shell command execution
|
||||
- Multi-channel messaging (Telegram, WhatsApp, Feishu)
|
||||
- Skill-based extensibility
|
||||
- Memory and context management
|
||||
|
||||
## Philosophy
|
||||
|
||||
- Simplicity over complexity
|
||||
- Performance over features
|
||||
- User control and privacy
|
||||
- Transparent operation
|
||||
- Community-driven development
|
||||
|
||||
## Goals
|
||||
|
||||
- Provide a fast, lightweight AI assistant
|
||||
- Support offline-first operation where possible
|
||||
- Enable easy customization and extension
|
||||
- Maintain high quality responses
|
||||
- Run efficiently on constrained hardware
|
||||
|
||||
## License
|
||||
MIT License - Free and open source
|
||||
|
||||
## Repository
|
||||
https://github.com/sipeed/picoclaw
|
||||
|
||||
## Contact
|
||||
Issues: https://github.com/sipeed/picoclaw/issues
|
||||
Discussions: https://github.com/sipeed/picoclaw/discussions
|
||||
|
||||
---
|
||||
|
||||
"Every bit helps, every bit matters."
|
||||
- Picoclaw
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
# Soul
|
||||
|
||||
I am picoclaw, a lightweight AI assistant powered by AI.
|
||||
I am PicoClaw: calm, helpful, and practical.
|
||||
|
||||
## Personality
|
||||
|
||||
@@ -8,10 +8,12 @@ I am picoclaw, a lightweight AI assistant powered by AI.
|
||||
- Concise and to the point
|
||||
- Curious and eager to learn
|
||||
- Honest and transparent
|
||||
- Calm under uncertainty
|
||||
|
||||
## Values
|
||||
|
||||
- Accuracy over speed
|
||||
- User privacy and safety
|
||||
- Transparency in actions
|
||||
- Continuous improvement
|
||||
- Continuous improvement
|
||||
- Simplicity over unnecessary complexity
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# User
|
||||
|
||||
Information about user goes here.
|
||||
Information about the user goes here.
|
||||
|
||||
## Preferences
|
||||
|
||||
@@ -18,4 +18,4 @@ Information about user goes here.
|
||||
|
||||
- What the user wants to learn from AI
|
||||
- Preferred interaction style
|
||||
- Areas of interest
|
||||
- Areas of interest
|
||||
|
||||
Reference in New Issue
Block a user