merge: resolve conflict with main by keeping both skillCatalogCfg and agentDiscovery

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Costin Stroie 2026-05-12 16:40:51 +03:00
commit 38063f4419
209 changed files with 30250 additions and 1730 deletions

View file

@ -56,6 +56,14 @@
## 📢 News
2026-05-11 🛒 **LicheeRV-Claw on AliExpress!** You can now purchase LicheeRV-Claw from [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), making it easier to try PicoClaw on compact RISC-V hardware.
<p align="center">
<a href="https://www.aliexpress.com/item/1005006519668532.html">
<img src="assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
</a>
</p>
2026-03-31 📱 **Android Support!** PicoClaw now runs on Android! Download the APK at [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 Released!** Agent architecture overhaul (SubTurn, Hooks, Steering, EventBus), WeChat/WeCom integration, security hardening (.security.yml, sensitive data filtering), new providers (AWS Bedrock, Azure, Xiaomi MiMo), and 35 bug fixes. PicoClaw has reached **26K Stars**!
@ -447,7 +455,7 @@ For full provider configuration details, see [Providers & Models](docs/guides/pr
## 💬 Channels (Chat Apps)
Talk to your PicoClaw through 18+ messaging platforms:
Talk to your PicoClaw through 19+ messaging platforms:
| Channel | Setup | Protocol | Docs |
|---------|-------|----------|------|
@ -465,6 +473,7 @@ Talk to your PicoClaw through 18+ messaging platforms:
| **VK** | Easy (group token) | Long Poll | [Guide](docs/channels/vk/README.md) |
| **IRC** | Medium (server + nick) | IRC protocol | [Guide](docs/guides/chat-apps.md#irc) |
| **OneBot** | Medium (WebSocket URL) | OneBot v11 | [Guide](docs/channels/onebot/README.md) |
| **MQTT** | Easy (broker + agent_id) | MQTT pub/sub | [Guide](docs/channels/mqtt/README.md) |
| **MaixCam** | Easy (enable) | TCP socket | [Guide](docs/channels/maixcam/README.md) |
| **Pico** | Easy (enable) | Native protocol | Built-in |
| **Pico Client** | Easy (WebSocket URL) | WebSocket | Built-in |
@ -484,7 +493,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too
| Search Engine | API Key | Free Tier | Link |
|--------------|---------|-----------|------|
| DuckDuckGo | Not needed | Unlimited | Built-in fallback |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1000 queries/day | AI-powered, China-optimized |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1500/month (daily allocation) | AI-powered, China-optimized |
| [Tavily](https://tavily.com) | Required | 1000 queries/month | Optimized for AI Agents |
| [Brave Search](https://brave.com/search/api) | Required | 2000 queries/month | Fast and private |
| [Perplexity](https://www.perplexity.ai) | Required | Paid | AI-powered search |
@ -617,7 +626,7 @@ For detailed guides beyond this README:
| Topic | Description |
|-------|-------------|
| [Docker & Quick Start](docs/guides/docker.md) | Docker Compose setup, Launcher/Agent modes |
| [Chat Apps](docs/guides/chat-apps.md) | All 17+ channel setup guides |
| [Chat Apps](docs/guides/chat-apps.md) | All 18+ channel setup guides |
| [Configuration](docs/guides/configuration.md) | Environment variables, workspace layout, security sandbox |
| [MCP Server CLI](docs/reference/mcp-cli.md) | Add, list, test, edit, and remove MCP server entries from the CLI |
| [Scheduled Tasks and Cron Jobs](docs/reference/cron.md) | Cron schedule types, deliver modes, command gates, job storage |

BIN
assets/licheerv-claw.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

View file

@ -20,6 +20,15 @@
}
}
},
"evolution": {
"enabled": false,
"mode": "observe",
"state_dir": "",
"min_task_count": 2,
"min_success_ratio": 0.7,
"cold_path_trigger": "after_turn",
"cold_path_times": []
},
"model_list": [
{
"model_name": "gpt-5.4",

View file

@ -7,6 +7,7 @@ Internal architecture notes for major runtime mechanisms and subsystem design.
- [Session System](session-system.md): session scope allocation, JSONL persistence, alias compatibility, and migration. ([ZH](session-system.zh.md))
- [Routing System](routing-system.md): agent dispatch, session policy selection, and light/heavy model routing. ([ZH](routing-system.zh.md))
- [Runtime Events](runtime-events.md): runtime event envelope, centralized event logging, filters, and examples. ([ZH](runtime-events.zh.md))
- [Agent Self-Evolution](agent-self-evolution.md): learning records, draft generation, application modes, and state layout.
- [Hook System Guide](hooks/README.md): current hook architecture and protocol details.
- [Agent Refactor](agent-refactor/README.md): notes and checkpoints for the agent refactor work.

View file

@ -0,0 +1,47 @@
# Agent Self-Evolution
Agent self-evolution lets PicoClaw learn from completed turns and turn repeated successful behavior into skill improvements. The runtime is controlled by the top-level `evolution` config block.
## Flow
The hot path runs at the end of an agent turn. When `evolution.enabled` is true, it records a learning record with the turn summary, success state, used skills, tool executions, and session/workspace metadata. Heartbeat turns are skipped.
The cold path groups related task records, checks the configured success threshold, and prepares skill drafts for patterns that have enough evidence. Drafts can target new skills or append/replace/merge existing workspace skills.
The apply path validates generated `SKILL.md` content before writing. Invalid drafts are rejected before a skill directory or file is created.
## Safety Considerations
Evolution creates a persistent feedback loop: user input can become a task record, task records can be clustered into an LLM-generated draft, and an accepted draft can become `SKILL.md` content that is loaded into future agent prompts. Treat generated skill content as prompt-sensitive material, especially in `apply` mode.
The current local scanner is a narrow guardrail, not a complete safety boundary. It rejects structurally invalid drafts and a small set of obvious secret-like substrings, but it does not reliably detect prompt injection, unsafe instructions, or every form of sensitive data. Use `observe` or `draft` when human review is required before skill changes reach disk.
In `apply` mode, accepted drafts can update workspace skills automatically. Existing skills are backed up before replacement, but recovery is manual: an operator must restore the desired backup if an applied skill should be rolled back.
## Modes
| Mode | Behavior |
|------|----------|
| `observe` | Record learning data only. No cold-path draft generation runs automatically. |
| `draft` | Record learning data and generate candidate skill drafts when the cold path runs. |
| `apply` | Generate drafts and allow accepted drafts to update workspace skills. |
When `evolution.enabled` is false, `mode` is treated as disabled at runtime.
## Cold Path Trigger
`cold_path_trigger` only matters in `draft` and `apply` modes.
| Trigger | Behavior |
|---------|----------|
| `after_turn` | Run the cold path after eligible turns. |
| `scheduled` | Run the cold path at configured `cold_path_times`. |
| `manual` | Do not run automatically. There is no user-facing Web/API/CLI trigger yet; code can still invoke `Runtime.RunColdPathOnce`. |
`cold_path_times` uses `HH:MM` strings and is ignored unless the trigger is `scheduled`.
## State
By default, evolution state is stored under the workspace. `state_dir` can redirect that state to another directory. The state includes learning records, clustered pattern records, drafts, and skill profiles.
For user-facing configuration fields, see the [Configuration Guide](../guides/configuration.md#agent-self-evolution).

View file

@ -0,0 +1,140 @@
# 📡 Canal MQTT
PicoClaw prend en charge n'importe quel client MQTT comme canal de messagerie. Les appareils ou services publient des requêtes vers un broker ; PicoClaw s'abonne, les traite et publie les réponses en retour.
## 🚀 Démarrage rapide
**1. Ajouter le canal dans `~/.picoclaw/config.json` :**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "tcp://localhost:1883",
"agent_id": "assistant"
}
}
}
}
```
**2. Démarrer la passerelle :**
```bash
picoclaw gateway
```
**3. Envoyer un message depuis n'importe quel client MQTT :**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "Quel est l'\''usage CPU ?"}'
```
**4. S'abonner pour recevoir la réponse :**
```bash
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
---
## 📨 Structure des topics
```
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
```
| Segment | Description |
|---------|-------------|
| `prefix` | Préfixe de topic configuré côté serveur. Défaut : `/picoclaw` |
| `agent_id` | Identifiant de l'instance PicoClaw, défini dans le champ `agent_id` |
| `client_id` | Identifiant de session défini par le client — utiliser un ID stable par appareil pour maintenir le contexte |
### Payload du message (JSON)
```json
{ "text": "votre message ici" }
```
---
## ⚙️ Configuration
### config.json
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://votre-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"client_id": "",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
### .security.yml (identifiants)
Le nom d'utilisateur et le mot de passe sont stockés dans `~/.picoclaw/.security.yml`, pas dans `config.json` :
```yaml
channel_list:
mqtt:
settings:
username: votre_utilisateur
password: votre_mot_de_passe
```
### Champs de configuration
| Champ | Emplacement | Requis | Défaut | Description |
|-------|-------------|--------|--------|-------------|
| `broker` | `settings` | Oui | — | URL du broker MQTT, ex. `tcp://host:1883`, `ssl://host:8883` |
| `agent_id` | `settings` | Oui | — | Identifiant de l'agent, utilisé dans le chemin du topic |
| `topic_prefix` | `settings` | Non | `/picoclaw` | Préfixe de l'espace de noms des topics |
| `username` | `.security.yml` | Non | — | Nom d'utilisateur pour l'authentification au broker |
| `password` | `.security.yml` | Non | — | Mot de passe pour l'authentification au broker |
| `client_id` | `settings` | Non | auto-généré | ID client paho envoyé au broker. Auto-généré sous la forme `picoclaw-mqtt-{agent_id}-{8 hex}` ; fixe pour la durée du processus, réutilisé à la reconnexion |
| `keep_alive` | `settings` | Non | `60` | Intervalle keepalive MQTT en secondes |
| `qos` | `settings` | Non | `0` | Niveau QoS pour la publication et l'abonnement : `0`, `1` ou `2` |
### Variables d'environnement
| Variable | Champ |
|----------|-------|
| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` |
| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` |
| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` |
| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` |
| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` |
| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` |
| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` |
| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` |
---
## 🔄 Reconnexion
PicoClaw se reconnecte automatiquement au broker en cas de perte de connexion, avec un intervalle de 5 secondes. L'abonnement est rétabli automatiquement. L'ID client côté broker reste identique à chaque reconnexion.
---
## ⚠️ Remarques
- **TLS** : SSL/TLS est supporté (URL broker en `ssl://`). La vérification du certificat est désactivée par défaut.
- **Réponses en streaming** : Les réponses en streaming envoient plusieurs messages vers le topic de réponse ; les concaténer dans l'ordre pour obtenir la réponse complète.
- **client_id vs ID de session** : Le `client_id` dans le chemin du topic est défini par votre application cliente. Il est distinct de l'ID client paho utilisé par PicoClaw pour se connecter au broker.
- **Instances multiples** : Si plusieurs instances PicoClaw utilisent le même `agent_id` sur le même broker, définir des `client_id` distincts pour éviter les conflits.

View file

@ -0,0 +1,140 @@
# 📡 MQTT チャンネル
PicoClaw は任意の MQTT クライアントをメッセージチャンネルとして使用できます。デバイスやサービスがブローカーにリクエストをパブリッシュし、PicoClaw がサブスクライブして処理し、レスポンスをパブリッシュして返します。
## 🚀 クイックスタート
**1. `~/.picoclaw/config.json` にチャンネルを追加:**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "tcp://localhost:1883",
"agent_id": "assistant"
}
}
}
}
```
**2. ゲートウェイを起動:**
```bash
picoclaw gateway
```
**3. 任意の MQTT クライアントからメッセージを送信:**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "CPU使用率を確認してください"}'
```
**4. レスポンスを受信するためにサブスクライブ:**
```bash
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
---
## 📨 トピック構造
```
{prefix}/{agent_id}/{client_id}/request # クライアント → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → クライアント
```
| セグメント | 説明 |
|-----------|------|
| `prefix` | トピックのプレフィックス。サーバー側で設定。デフォルト:`/picoclaw` |
| `agent_id` | PicoClaw インスタンスの識別子。`agent_id` フィールドに設定 |
| `client_id` | クライアントが定義するセッション識別子。デバイスごとに同一の ID を使用するとコンテキストが維持される |
### メッセージペイロードJSON
```json
{ "text": "メッセージ内容" }
```
---
## ⚙️ 設定
### config.json
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://your-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"client_id": "",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
### .security.yml認証情報
ユーザー名とパスワードは `config.json` ではなく `~/.picoclaw/.security.yml` に保存します:
```yaml
channel_list:
mqtt:
settings:
username: your_username
password: your_password
```
### 設定フィールド
| フィールド | 場所 | 必須 | デフォルト | 説明 |
|-----------|------|------|-----------|------|
| `broker` | `settings` | はい | — | MQTT ブローカー URL。例`tcp://host:1883``ssl://host:8883` |
| `agent_id` | `settings` | はい | — | エージェント識別子。トピックパスの一部として使用される |
| `topic_prefix` | `settings` | いいえ | `/picoclaw` | トピックの名前空間プレフィックス |
| `username` | `.security.yml` | いいえ | — | ブローカー認証のユーザー名 |
| `password` | `.security.yml` | いいえ | — | ブローカー認証のパスワード |
| `client_id` | `settings` | いいえ | 自動生成 | ブローカーに送信する paho クライアント ID。未設定の場合 `picoclaw-mqtt-{agent_id}-{8桁hex}` で自動生成。プロセスの生存期間中は固定され、再接続時も同じ ID を使用 |
| `keep_alive` | `settings` | いいえ | `60` | MQTT キープアライブ間隔(秒) |
| `qos` | `settings` | いいえ | `0` | パブリッシュおよびサブスクライブの QoS レベル:`0``1``2` |
### 環境変数
| 変数 | フィールド |
|------|----------|
| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` |
| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` |
| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` |
| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` |
| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` |
| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` |
| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` |
| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` |
---
## 🔄 再接続
接続が切断された場合、PicoClaw は 5 秒間隔で自動的にブローカーに再接続します。再接続後はサブスクリプションも自動的に再確立されます。再接続時はブローカー側のクライアント ID が同一に保たれるため、ブローカーは同じセッションとして認識します。
---
## ⚠️ 注意事項
- **TLS**SSL/TLS をサポートしています(ブローカー URL に `ssl://` を使用)。デフォルトでは証明書検証をスキップします。
- **ストリーミングレスポンス**:ストリーミング出力時はレスポンストピックに複数のメッセージが送信されます。順番に結合すると完全なレスポンスになります。
- **client_id とセッション ID の違い**:トピックパスの `client_id` はクライアントアプリケーションが設定するセッション識別子です。PicoClaw がブローカーへの接続に使用する paho クライアント ID とは別の概念です。
- **複数インスタンス**:同じ `agent_id` で複数の PicoClaw インスタンスを同一ブローカーに接続する場合、ブローカーレベルの競合を避けるために各インスタンスに異なる `client_id` を設定してください。

View file

@ -0,0 +1,142 @@
# 📡 MQTT Channel
PicoClaw supports any MQTT client as a chat channel. Devices or services publish requests to a broker; PicoClaw subscribes, processes them, and publishes responses back.
## 🚀 Quick Start
**1. Add the channel to `~/.picoclaw/config.json`:**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "tcp://localhost:1883",
"agent_id": "assistant"
}
}
}
}
```
**2. Start the gateway:**
```bash
picoclaw gateway
```
**3. Send a message from any MQTT client:**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "What is the CPU usage?"}'
```
**4. Subscribe to receive the response:**
```bash
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
---
## 📨 Topic Structure
```
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
```
| Segment | Description |
|---------|-------------|
| `prefix` | Topic prefix, configured server-side. Default: `/picoclaw` |
| `agent_id` | PicoClaw instance identifier, set in `agent_id` config field |
| `client_id` | Client-defined session identifier — use a stable ID per device to maintain conversation context |
### Message Payload (JSON)
```json
{ "text": "your message here" }
```
---
## ⚙️ Configuration
### config.json
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://your-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"client_id": "",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
### .security.yml (credentials)
Username and password are stored in `~/.picoclaw/.security.yml`, not in `config.json`:
```yaml
channel_list:
mqtt:
settings:
username: your_username
password: your_password
```
### Configuration Fields
| Field | Location | Required | Default | Description |
|-------|----------|----------|---------|-------------|
| `broker` | `settings` | Yes | — | MQTT broker URL, e.g. `tcp://host:1883`, `ssl://host:8883` |
| `agent_id` | `settings` | Yes | — | Agent identifier, used as part of the topic path |
| `topic_prefix` | `settings` | No | `/picoclaw` | Topic namespace prefix |
| `username` | `.security.yml` | No | — | Broker authentication username |
| `password` | `.security.yml` | No | — | Broker authentication password |
| `client_id` | `settings` | No | auto-generated | Paho client ID sent to the broker. Auto-generated as `picoclaw-mqtt-{agent_id}-{8-char hex}` if not set; stays fixed for the process lifetime so reconnects reuse the same ID |
| `keep_alive` | `settings` | No | `60` | MQTT keepalive interval in seconds |
| `qos` | `settings` | No | `0` | QoS level for publish and subscribe: `0`, `1`, or `2` |
### Environment Variables
All fields can be set via environment variables:
| Variable | Field |
|----------|-------|
| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` |
| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` |
| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` |
| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` |
| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` |
| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` |
| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` |
| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` |
---
## 🔄 Reconnection
PicoClaw automatically reconnects to the broker if the connection is lost, with a 5-second retry interval. On reconnect, the subscription is re-established automatically. The broker-side client ID stays the same across reconnects so the broker correctly identifies it as the same session.
---
## ⚠️ Notes
- **TLS**: SSL/TLS is supported (`ssl://` broker URL). Certificate verification is skipped by default.
- **Streaming**: Streaming responses send multiple messages to the response topic; concatenate them in order.
- **client_id vs session ID**: The `client_id` in the topic path is set by your client application and identifies the conversation session. It is separate from the broker-level client ID used by PicoClaw's paho connection.
- **Multiple instances**: If you run multiple PicoClaw instances against the same broker with the same `agent_id`, set distinct `client_id` values to avoid broker-level conflicts.

View file

@ -0,0 +1,140 @@
# 📡 Canal MQTT
O PicoClaw suporta qualquer cliente MQTT como canal de mensagens. Dispositivos ou serviços publicam requisições para um broker; o PicoClaw assina, processa e publica as respostas de volta.
## 🚀 Início rápido
**1. Adicione o canal ao `~/.picoclaw/config.json`:**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "tcp://localhost:1883",
"agent_id": "assistant"
}
}
}
}
```
**2. Inicie o gateway:**
```bash
picoclaw gateway
```
**3. Envie uma mensagem de qualquer cliente MQTT:**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "Qual é o uso de CPU?"}'
```
**4. Assine para receber a resposta:**
```bash
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
---
## 📨 Estrutura de tópicos
```
{prefix}/{agent_id}/{client_id}/request # Cliente → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Cliente
```
| Segmento | Descrição |
|----------|-----------|
| `prefix` | Prefixo do tópico configurado no servidor. Padrão: `/picoclaw` |
| `agent_id` | Identificador da instância do PicoClaw, definido no campo `agent_id` |
| `client_id` | Identificador de sessão definido pelo cliente — use um ID estável por dispositivo para manter o contexto da conversa |
### Payload da mensagem (JSON)
```json
{ "text": "sua mensagem aqui" }
```
---
## ⚙️ Configuração
### config.json
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://seu-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"client_id": "",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
### .security.yml (credenciais)
O nome de usuário e a senha são armazenados em `~/.picoclaw/.security.yml`, não no `config.json`:
```yaml
channel_list:
mqtt:
settings:
username: seu_usuario
password: sua_senha
```
### Campos de configuração
| Campo | Local | Obrigatório | Padrão | Descrição |
|-------|-------|-------------|--------|-----------|
| `broker` | `settings` | Sim | — | URL do broker MQTT, ex. `tcp://host:1883`, `ssl://host:8883` |
| `agent_id` | `settings` | Sim | — | Identificador do agente, usado como parte do caminho do tópico |
| `topic_prefix` | `settings` | Não | `/picoclaw` | Prefixo do namespace dos tópicos |
| `username` | `.security.yml` | Não | — | Nome de usuário para autenticação no broker |
| `password` | `.security.yml` | Não | — | Senha para autenticação no broker |
| `client_id` | `settings` | Não | gerado automaticamente | ID de cliente paho enviado ao broker. Gerado automaticamente como `picoclaw-mqtt-{agent_id}-{8 hex}` se não definido; fixo durante o tempo de vida do processo e reutilizado nas reconexões |
| `keep_alive` | `settings` | Não | `60` | Intervalo de keepalive MQTT em segundos |
| `qos` | `settings` | Não | `0` | Nível de QoS para publicação e assinatura: `0`, `1` ou `2` |
### Variáveis de ambiente
| Variável | Campo |
|----------|-------|
| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` |
| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` |
| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` |
| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` |
| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` |
| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` |
| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` |
| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` |
---
## 🔄 Reconexão
O PicoClaw reconecta automaticamente ao broker se a conexão for perdida, com intervalo de 5 segundos. Após a reconexão, a assinatura é restabelecida automaticamente. O ID de cliente no broker permanece o mesmo nas reconexões, permitindo que o broker identifique corretamente a mesma sessão.
---
## ⚠️ Observações
- **TLS**: SSL/TLS é suportado (URL do broker com `ssl://`). A verificação de certificado é ignorada por padrão.
- **Respostas em streaming**: Respostas em streaming enviam múltiplas mensagens para o tópico de resposta; concatene-as na ordem recebida para obter a resposta completa.
- **client_id vs ID de sessão**: O `client_id` no caminho do tópico é definido pela sua aplicação cliente e identifica a sessão. É separado do ID de cliente paho usado pelo PicoClaw para se conectar ao broker.
- **Múltiplas instâncias**: Se várias instâncias do PicoClaw usarem o mesmo `agent_id` no mesmo broker, defina `client_id` distintos para evitar conflitos no nível do broker.

View file

@ -0,0 +1,140 @@
# 📡 Kênh MQTT
PicoClaw hỗ trợ bất kỳ client MQTT nào làm kênh nhắn tin. Thiết bị hoặc dịch vụ publish yêu cầu lên broker; PicoClaw subscribe, xử lý và publish phản hồi trở lại.
## 🚀 Bắt đầu nhanh
**1. Thêm kênh vào `~/.picoclaw/config.json`:**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "tcp://localhost:1883",
"agent_id": "assistant"
}
}
}
}
```
**2. Khởi động gateway:**
```bash
picoclaw gateway
```
**3. Gửi tin nhắn từ bất kỳ client MQTT nào:**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "CPU đang dùng bao nhiêu phần trăm?"}'
```
**4. Subscribe để nhận phản hồi:**
```bash
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
---
## 📨 Cấu trúc topic
```
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
```
| Phân đoạn | Mô tả |
|-----------|-------|
| `prefix` | Tiền tố topic, cấu hình phía server. Mặc định: `/picoclaw` |
| `agent_id` | Định danh instance PicoClaw, đặt trong trường `agent_id` |
| `client_id` | Định danh phiên do client xác định — dùng ID ổn định cho mỗi thiết bị để duy trì ngữ cảnh hội thoại |
### Payload tin nhắn (JSON)
```json
{ "text": "nội dung tin nhắn" }
```
---
## ⚙️ Cấu hình
### config.json
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://your-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"client_id": "",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
### .security.yml (thông tin xác thực)
Tên người dùng và mật khẩu được lưu trong `~/.picoclaw/.security.yml`, không phải trong `config.json`:
```yaml
channel_list:
mqtt:
settings:
username: ten_nguoi_dung
password: mat_khau
```
### Các trường cấu hình
| Trường | Vị trí | Bắt buộc | Mặc định | Mô tả |
|--------|--------|----------|----------|-------|
| `broker` | `settings` | Có | — | URL của MQTT broker, ví dụ `tcp://host:1883`, `ssl://host:8883` |
| `agent_id` | `settings` | Có | — | Định danh agent, dùng làm một phần của đường dẫn topic |
| `topic_prefix` | `settings` | Không | `/picoclaw` | Tiền tố không gian tên topic |
| `username` | `.security.yml` | Không | — | Tên người dùng xác thực với broker |
| `password` | `.security.yml` | Không | — | Mật khẩu xác thực với broker |
| `client_id` | `settings` | Không | tự động tạo | Client ID paho gửi đến broker. Tự động tạo dạng `picoclaw-mqtt-{agent_id}-{8 hex}` nếu không đặt; cố định trong suốt vòng đời tiến trình, tái sử dụng khi kết nối lại |
| `keep_alive` | `settings` | Không | `60` | Khoảng thời gian keepalive MQTT (giây) |
| `qos` | `settings` | Không | `0` | Mức QoS cho publish và subscribe: `0`, `1` hoặc `2` |
### Biến môi trường
| Biến | Trường |
|------|--------|
| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` |
| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` |
| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` |
| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` |
| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` |
| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` |
| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` |
| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` |
---
## 🔄 Kết nối lại
PicoClaw tự động kết nối lại với broker nếu mất kết nối, với khoảng thời gian thử lại 5 giây. Sau khi kết nối lại, subscription được tái thiết lập tự động. Client ID phía broker giữ nguyên qua các lần kết nối lại, giúp broker nhận diện chính xác cùng một phiên.
---
## ⚠️ Lưu ý
- **TLS**: Hỗ trợ SSL/TLS (URL broker dùng `ssl://`). Mặc định bỏ qua xác minh chứng chỉ.
- **Phản hồi streaming**: Phản hồi streaming gửi nhiều tin nhắn đến topic response; ghép nối chúng theo thứ tự để có phản hồi đầy đủ.
- **client_id và ID phiên**: `client_id` trong đường dẫn topic được đặt bởi ứng dụng client của bạn và xác định phiên hội thoại. Nó khác với client ID paho mà PicoClaw dùng để kết nối broker.
- **Nhiều instance**: Nếu nhiều instance PicoClaw dùng cùng `agent_id` trên cùng broker, hãy đặt `client_id` riêng biệt cho từng instance để tránh xung đột ở tầng broker.

View file

@ -0,0 +1,142 @@
# 📡 MQTT 渠道
PicoClaw 支持将任意 MQTT 客户端作为消息渠道。设备或服务向 Broker 发布请求PicoClaw 订阅后处理并将响应发布回去。
## 🚀 快速开始
**1. 在 `~/.picoclaw/config.json` 中添加渠道:**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "tcp://localhost:1883",
"agent_id": "assistant"
}
}
}
}
```
**2. 启动网关:**
```bash
picoclaw gateway
```
**3. 用任意 MQTT 客户端发送消息:**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "查一下CPU使用率"}'
```
**4. 订阅响应:**
```bash
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
---
## 📨 Topic 结构
```
{prefix}/{agent_id}/{client_id}/request # 客户端 → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → 客户端
```
| 段 | 说明 |
|----|------|
| `prefix` | Topic 前缀,由服务端配置,默认 `/picoclaw` |
| `agent_id` | PicoClaw 实例标识,对应配置中的 `agent_id` 字段 |
| `client_id` | 客户端自定义会话标识——同一设备保持相同 ID 可维持上下文连续性 |
### 消息体JSON
```json
{ "text": "你的消息内容" }
```
---
## ⚙️ 配置说明
### config.json
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://your-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"client_id": "",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
### .security.yml用户名和密码
用户名和密码存储于 `~/.picoclaw/.security.yml`,不写入 `config.json`
```yaml
channel_list:
mqtt:
settings:
username: your_username
password: your_password
```
### 字段说明
| 字段 | 位置 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| `broker` | `settings` | 是 | — | MQTT Broker 地址,如 `tcp://host:1883``ssl://host:8883` |
| `agent_id` | `settings` | 是 | — | Agent 标识,作为 topic 路径的一部分 |
| `topic_prefix` | `settings` | 否 | `/picoclaw` | Topic 命名空间前缀 |
| `username` | `.security.yml` | 否 | — | Broker 认证用户名 |
| `password` | `.security.yml` | 否 | — | Broker 认证密码 |
| `client_id` | `settings` | 否 | 自动生成 | 发送给 Broker 的 paho 客户端 ID。未配置时自动生成为 `picoclaw-mqtt-{agent_id}-{8位hex}`,进程生命周期内固定不变,断线重连时复用同一 ID |
| `keep_alive` | `settings` | 否 | `60` | MQTT 心跳间隔(秒) |
| `qos` | `settings` | 否 | `0` | 发布和订阅的 QoS 级别:`0``1``2` |
### 环境变量
所有字段均可通过环境变量配置:
| 环境变量 | 对应字段 |
|----------|----------|
| `PICOCLAW_CHANNELS_MQTT_BROKER` | `broker` |
| `PICOCLAW_CHANNELS_MQTT_AGENT_ID` | `agent_id` |
| `PICOCLAW_CHANNELS_MQTT_TOPIC_PREFIX` | `topic_prefix` |
| `PICOCLAW_CHANNELS_MQTT_USERNAME` | `username` |
| `PICOCLAW_CHANNELS_MQTT_PASSWORD` | `password` |
| `PICOCLAW_CHANNELS_MQTT_CLIENT_ID` | `client_id` |
| `PICOCLAW_CHANNELS_MQTT_KEEP_ALIVE` | `keep_alive` |
| `PICOCLAW_CHANNELS_MQTT_QOS` | `qos` |
---
## 🔄 断线重连
连接断开后 PicoClaw 会自动以 5 秒间隔重连 Broker重连成功后自动重新订阅。断线重连时复用相同的 Broker 客户端 IDBroker 能正确识别为同一连接。
---
## ⚠️ 注意事项
- **TLS**:支持 SSL/TLSBroker 地址使用 `ssl://`),默认跳过证书验证。
- **流式响应**:流式输出时会向 response topic 发送多条消息,客户端按顺序拼接即为完整回复。
- **client_id 与会话 ID 的区别**topic 路径中的 `client_id` 由客户端应用自行设置,用于区分会话;它与 PicoClaw paho 连接 Broker 时使用的客户端 ID 是两个独立的概念。
- **多实例部署**:若多个 PicoClaw 实例使用相同 `agent_id` 连接同一 Broker需为每个实例配置不同的 `client_id` 以避免 Broker 层面的冲突。

View file

@ -15,7 +15,8 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
"allow_from": ["123456789"],
"proxy": "",
"use_markdown_v2": false
"use_markdown_v2": false,
"media_group_delay_ms": 500
}
}
}
@ -28,6 +29,7 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
| proxy | string | No | Proxy URL for connecting to the Telegram API (e.g. http://127.0.0.1:7890) |
| use_markdown_v2 | bool | No | Enable Telegram MarkdownV2 formatting |
| media_group_delay_ms | int | No | Idle delay before processing Telegram media groups/albums. Defaults to 500 ms |
## Setup

View file

@ -4,7 +4,7 @@
## 💬 Applications de Chat
Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot ou MaixCam.
Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT ou MaixCam.
> **Note** : Tous les canaux basés sur les webhooks (LINE, WeCom, etc.) sont servis sur un seul serveur HTTP Gateway partagé (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Il n'y a pas de ports par canal à configurer. Note : Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP webhook partagé.
@ -23,6 +23,7 @@ Communiquez avec votre PicoClaw via Telegram, Discord, WhatsApp, Matrix, QQ, Din
| **Feishu (飞书)** | ⭐⭐⭐ Avancé | Collaboration entreprise, fonctionnalités riches | [Documentation](../channels/feishu/README.fr.md) |
| **IRC** | ⭐⭐ Moyen | Serveur + configuration TLS | [Documentation](#irc) |
| **OneBot** | ⭐⭐ Moyen | Compatible NapCat/Go-CQHTTP, écosystème communautaire | [Documentation](../channels/onebot/README.fr.md) |
| **MQTT** | ⭐ Facile | N'importe quel client MQTT via broker pub/sub | [Documentation](../channels/mqtt/README.fr.md) |
| **MaixCam** | ⭐ Facile | Canal d'intégration matérielle pour caméras AI Sipeed | [Documentation](../channels/maixcam/README.fr.md) |
| **Pico** | ⭐ Facile | Canal protocole natif PicoClaw | |
@ -681,3 +682,67 @@ picoclaw gateway
```
</details>
<a id="mqtt"></a>
<details>
<summary><b>MQTT</b></summary>
N'importe quel client MQTT peut communiquer avec PicoClaw via un broker. Les appareils ou services publient des requêtes vers le broker ; PicoClaw s'abonne, les traite et publie les réponses en retour.
**1. Configurer**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://votre-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
Nom d'utilisateur et mot de passe dans `~/.picoclaw/.security.yml` :
```yaml
channel_list:
mqtt:
settings:
username: votre_utilisateur
password: votre_mot_de_passe
```
**Format des topics**
```
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
```
Le `client_id` est défini par votre application cliente pour identifier les appareils ou sessions.
**2. Lancer**
```bash
picoclaw gateway
```
**3. Tester**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "Bonjour"}'
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
Pour les options complètes, voir [Documentation du canal MQTT](../channels/mqtt/README.fr.md).
</details>

View file

@ -25,6 +25,7 @@ PicoClaw は複数のチャットプラットフォームをサポートして
| **Feishu (飛書)** | ⭐⭐⭐ やや難 | エンタープライズコラボレーション、機能豊富 | [ドキュメント](../channels/feishu/README.ja.md) |
| **IRC** | ⭐⭐ 中程度 | サーバー + TLS 設定 | [ドキュメント](#irc) |
| **OneBot** | ⭐⭐ 中程度 | NapCat/Go-CQHTTP 互換、コミュニティエコシステム充実 | [ドキュメント](../channels/onebot/README.ja.md) |
| **MQTT** | ⭐ 簡単 | ブローカー経由で任意の MQTT クライアントと通信 | [ドキュメント](../channels/mqtt/README.ja.md) |
| **MaixCam** | ⭐ 簡単 | Sipeed AI カメラハードウェア統合チャネル | [ドキュメント](../channels/maixcam/README.ja.md) |
| **Pico** | ⭐ 簡単 | PicoClaw ネイティブプロトコルチャネル | |
@ -670,3 +671,67 @@ picoclaw gateway
```
</details>
<a id="mqtt"></a>
<details>
<summary><b>MQTT</b></summary>
任意の MQTT クライアントがブローカーを介して PicoClaw と通信できます。デバイスやサービスがブローカーにリクエストをパブリッシュし、PicoClaw がサブスクライブして処理し、レスポンスをパブリッシュして返します。
**1. 設定**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://your-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
ユーザー名とパスワードは `~/.picoclaw/.security.yml` に記載します:
```yaml
channel_list:
mqtt:
settings:
username: your_username
password: your_password
```
**トピック形式**
```
{prefix}/{agent_id}/{client_id}/request # クライアント → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → クライアント
```
`client_id` はクライアントアプリケーションがデバイスやセッションを識別するために設定します。
**2. 起動**
```bash
picoclaw gateway
```
**3. テスト**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "こんにちは"}'
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
完全な設定オプションは [MQTT チャンネルドキュメント](../channels/mqtt/README.ja.md) を参照してください。
</details>

View file

@ -4,7 +4,7 @@
## 💬 Chat Apps
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MaixCam, or Pico (native protocol)
Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT, MaixCam, or Pico (native protocol)
> **Note**: Channels that rely on HTTP callbacks share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Socket/stream-based channels such as Feishu, DingTalk, and WeCom do not rely on the shared webhook server for inbound delivery.
@ -23,6 +23,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk,
| **Feishu (飞书)** | ⭐⭐⭐ Advanced | Enterprise collaboration, feature-rich | [Docs](../channels/feishu/README.md) |
| **IRC** | ⭐⭐ Medium | Server + TLS configuration | [Docs](#irc) |
| **OneBot** | ⭐⭐ Medium | NapCat/Go-CQHTTP compatible, community ecosystem | [Docs](../channels/onebot/README.md) |
| **MQTT** | ⭐ Easy | Any MQTT client via broker pub/sub | [Docs](../channels/mqtt/README.md) |
| **MaixCam** | ⭐ Easy | Hardware integration channel for Sipeed AI cameras | [Docs](../channels/maixcam/README.md) |
| **Pico** | ⭐ Easy | Native PicoClaw protocol channel | |
@ -587,3 +588,69 @@ picoclaw gateway
```
</details>
<a id="mqtt"></a>
<details>
<summary><b>MQTT</b></summary>
Any MQTT client can communicate with PicoClaw via a broker. Devices or services publish requests to the broker; PicoClaw subscribes, processes them, and publishes responses back.
**1. Configure**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://your-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
Username and password go in `~/.picoclaw/.security.yml`:
```yaml
channel_list:
mqtt:
settings:
username: your_username
password: your_password
```
**Topic format**
```
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
```
`client_id` is set by your client application to identify different devices or sessions.
**2. Run**
```bash
picoclaw gateway
```
**3. Test**
```bash
# Send a message
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "Hello"}'
# Subscribe to responses
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
For full configuration options see [MQTT Channel Docs](../channels/mqtt/README.md).
</details>

View file

@ -4,7 +4,7 @@
## 💬 Aplikasi Sembang
Berbual dengan picoclaw anda melalui Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MaixCam, atau Pico (protokol asli)
Berbual dengan picoclaw anda melalui Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT, MaixCam, atau Pico (protokol asli)
> **Nota**: Semua saluran berasaskan webhook (LINE, WeCom, dan sebagainya) diservis pada satu pelayan HTTP Gateway yang dikongsi (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). Tiada port khusus per saluran untuk dikonfigurasikan. Nota: Feishu menggunakan mod WebSocket/SDK dan tidak menggunakan pelayan HTTP webhook yang dikongsi.
@ -22,6 +22,7 @@ Berbual dengan picoclaw anda melalui Telegram, Discord, WhatsApp, Matrix, QQ, Di
| **Slack** | Sederhana (Bot token + App token) |
| **IRC** | Sederhana (pelayan + konfigurasi TLS) |
| **OneBot** | Sederhana (QQ melalui protokol OneBot) |
| **MQTT** | Mudah (broker + agent_id) |
| **MaixCam** | Mudah (integrasi perkakasan Sipeed) |
| **Pico** | Protokol PicoClaw asli |
@ -445,3 +446,67 @@ picoclaw gateway
> **Nota**: WeCom AI Bot menggunakan protokol streaming pull — tiada isu timeout balasan. Tugasan panjang (>30 saat) akan bertukar secara automatik kepada penghantaran push `response_url`.
</details>
<a id="mqtt"></a>
<details>
<summary><b>MQTT</b></summary>
Mana-mana client MQTT boleh berkomunikasi dengan PicoClaw melalui broker. Peranti atau perkhidmatan menerbitkan permintaan ke broker; PicoClaw melanggan, memproses dan menerbitkan respons kembali.
**1. Konfigurasi**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://your-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
Nama pengguna dan kata laluan dalam `~/.picoclaw/.security.yml`:
```yaml
channel_list:
mqtt:
settings:
username: nama_pengguna
password: kata_laluan
```
**Format topik**
```
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
```
`client_id` ditetapkan oleh aplikasi client anda untuk mengenal pasti peranti atau sesi.
**2. Jalankan**
```bash
picoclaw gateway
```
**3. Uji**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "Helo"}'
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
Untuk semua pilihan konfigurasi, lihat [Dokumentasi Saluran MQTT](../channels/mqtt/README.md).
</details>

View file

@ -4,7 +4,7 @@
## 💬 Aplicativos de Chat
Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot ou MaixCam
Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT ou MaixCam
> **Nota**: Todos os canais baseados em webhook (LINE, WeCom, etc.) são servidos em um único servidor HTTP Gateway compartilhado (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). Não há portas por canal para configurar. Nota: Feishu usa o modo WebSocket/SDK e não utiliza o servidor HTTP webhook compartilhado.
@ -23,6 +23,7 @@ Converse com seu picoclaw através do Telegram, Discord, WhatsApp, Matrix, QQ, D
| **Feishu (飞书)** | ⭐⭐⭐ Avançado | Colaboração empresarial, rico em recursos | [Documentação](../channels/feishu/README.pt-br.md) |
| **IRC** | ⭐⭐ Médio | Servidor + configuração TLS | [Documentação](#irc) |
| **OneBot** | ⭐⭐ Médio | Compatível com NapCat/Go-CQHTTP, ecossistema comunitário | [Documentação](../channels/onebot/README.pt-br.md) |
| **MQTT** | ⭐ Fácil | Qualquer cliente MQTT via broker pub/sub | [Documentação](../channels/mqtt/README.pt-br.md) |
| **MaixCam** | ⭐ Fácil | Canal de integração de hardware para câmeras AI Sipeed | [Documentação](../channels/maixcam/README.pt-br.md) |
| **Pico** | ⭐ Fácil | Canal de protocolo nativo PicoClaw | |
@ -695,3 +696,67 @@ picoclaw gateway
```
</details>
<a id="mqtt"></a>
<details>
<summary><b>MQTT</b></summary>
Qualquer cliente MQTT pode se comunicar com o PicoClaw via broker. Dispositivos ou serviços publicam requisições para o broker; o PicoClaw assina, processa e publica as respostas de volta.
**1. Configurar**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://seu-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
Nome de usuário e senha em `~/.picoclaw/.security.yml`:
```yaml
channel_list:
mqtt:
settings:
username: seu_usuario
password: sua_senha
```
**Formato dos tópicos**
```
{prefix}/{agent_id}/{client_id}/request # Cliente → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Cliente
```
O `client_id` é definido pela sua aplicação cliente para identificar dispositivos ou sessões.
**2. Iniciar**
```bash
picoclaw gateway
```
**3. Testar**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "Olá"}'
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
Para todas as opções de configuração, veja a [Documentação do Canal MQTT](../channels/mqtt/README.pt-br.md).
</details>

View file

@ -4,7 +4,7 @@
## 💬 Ứng Dụng Chat
Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot hoặc MaixCam
Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, WeCom, Feishu, Slack, IRC, OneBot, MQTT hoặc MaixCam
> **Lưu ý**: Tất cả các kênh dựa trên webhook (LINE, WeCom, v.v.) được phục vụ trên một máy chủ HTTP Gateway chung (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Không có port riêng cho từng kênh. Lưu ý: Feishu sử dụng chế độ WebSocket/SDK và không sử dụng máy chủ HTTP webhook chung.
@ -23,6 +23,7 @@ Trò chuyện với picoclaw của bạn qua Telegram, Discord, WhatsApp, Matrix
| **Feishu (飞书)** | ⭐⭐⭐ Nâng cao | Cộng tác doanh nghiệp, nhiều tính năng | [Tài liệu](../channels/feishu/README.vi.md) |
| **IRC** | ⭐⭐ Trung bình | Máy chủ + cấu hình TLS | [Tài liệu](#irc) |
| **OneBot** | ⭐⭐ Trung bình | Tương thích NapCat/Go-CQHTTP, hệ sinh thái cộng đồng | [Tài liệu](../channels/onebot/README.vi.md) |
| **MQTT** | ⭐ Dễ | Bất kỳ client MQTT nào qua broker pub/sub | [Tài liệu](../channels/mqtt/README.vi.md) |
| **MaixCam** | ⭐ Dễ | Kênh tích hợp phần cứng cho camera AI Sipeed | [Tài liệu](../channels/maixcam/README.vi.md) |
| **Pico** | ⭐ Dễ | Kênh giao thức bản địa PicoClaw | |
@ -696,3 +697,67 @@ picoclaw gateway
```
</details>
<a id="mqtt"></a>
<details>
<summary><b>MQTT</b></summary>
Bất kỳ client MQTT nào đều có thể giao tiếp với PicoClaw qua broker. Thiết bị hoặc dịch vụ publish yêu cầu lên broker; PicoClaw subscribe, xử lý và publish phản hồi trở lại.
**1. Cấu hình**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://your-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
Tên người dùng và mật khẩu trong `~/.picoclaw/.security.yml`:
```yaml
channel_list:
mqtt:
settings:
username: ten_nguoi_dung
password: mat_khau
```
**Định dạng topic**
```
{prefix}/{agent_id}/{client_id}/request # Client → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → Client
```
`client_id` do ứng dụng client đặt để phân biệt thiết bị hoặc phiên.
**2. Khởi động**
```bash
picoclaw gateway
```
**3. Kiểm tra**
```bash
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "Xin chào"}'
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
Xem đầy đủ tùy chọn cấu hình tại [Tài liệu Kênh MQTT](../channels/mqtt/README.vi.md).
</details>

View file

@ -4,7 +4,7 @@
## 💬 聊天应用集成 (Chat Apps)
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方,包括 Telegram、Discord、WhatsApp、微信、QQ、钉钉、LINE、企业微信、飞书、Slack、IRC、OneBot、MQTT、MaixCam 等
> **注意**: 依赖 HTTP 回调的渠道共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`),无需为每个渠道单独配置端口。飞书、钉钉、企业微信这类 Socket/Stream 模式渠道不依赖共享 webhook 服务器来接收入站消息。
@ -25,6 +25,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](../channels/feishu/README.zh.md) |
| **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | [查看文档](#irc) |
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP社区生态丰富 | [查看文档](../channels/onebot/README.zh.md) |
| **MQTT** | ⭐ 简单 | 任意 MQTT 客户端通过 Broker 收发消息 | [查看文档](../channels/mqtt/README.zh.md) |
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](../channels/maixcam/README.zh.md) |
| **Pico** | ⭐ 简单 | PicoClaw 原生协议通道 | |
@ -610,3 +611,69 @@ picoclaw gateway
```
</details>
<a id="mqtt"></a>
<details>
<summary><b>MQTT</b></summary>
任意 MQTT 客户端均可通过 Broker 与 PicoClaw 通信。设备或服务向 Broker 发布请求PicoClaw 订阅后处理并将响应发布回去。
**1. 配置**
```json
{
"channel_list": {
"mqtt": {
"enabled": true,
"type": "mqtt",
"settings": {
"broker": "ssl://your-broker:8883",
"agent_id": "assistant",
"topic_prefix": "/picoclaw",
"keep_alive": 60,
"qos": 0
}
}
}
}
```
用户名和密码存储于 `~/.picoclaw/.security.yml`
```yaml
channel_list:
mqtt:
settings:
username: your_username
password: your_password
```
**Topic 格式**
```
{prefix}/{agent_id}/{client_id}/request # 客户端 → PicoClaw
{prefix}/{agent_id}/{client_id}/response # PicoClaw → 客户端
```
`client_id` 由客户端自行指定,用于区分不同设备或会话。
**2. 运行**
```bash
picoclaw gateway
```
**3. 测试**
```bash
# 发送消息
mosquitto_pub -t "/picoclaw/assistant/device1/request" \
-m '{"text": "你好"}'
# 订阅响应
mosquitto_sub -t "/picoclaw/assistant/device1/response"
```
完整配置选项请参考 [MQTT 渠道文档](../channels/mqtt/README.zh.md)。
</details>

View file

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

View file

@ -69,6 +69,36 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
> **Note:** Changes to `AGENT.md`, `SOUL.md`, `USER.md` and `memory/MEMORY.md` are automatically detected at runtime via file modification time (mtime) tracking. You do **not** need to restart the gateway after editing these files — the agent picks up the new content on the next request.
### Agent Self-Evolution
The `evolution` block controls PicoClaw's self-evolution runtime. When enabled, the agent records completed turns as learning records. In higher modes it can group repeated successful patterns, generate skill drafts, and optionally apply accepted drafts into workspace skills.
```json
{
"evolution": {
"enabled": false,
"mode": "observe",
"state_dir": "",
"min_task_count": 2,
"min_success_ratio": 0.7,
"cold_path_trigger": "after_turn",
"cold_path_times": []
}
}
```
| Field | Default | Description |
|-------|---------|-------------|
| `enabled` | `false` | Enables learning-record capture for completed agent turns. Heartbeat turns are ignored. |
| `mode` | `observe` | `observe` records data only. `draft` can generate candidate skill drafts. `apply` can apply accepted drafts to workspace skills. |
| `state_dir` | `""` | Optional directory for evolution state. Leave empty to use the default under the workspace. |
| `min_task_count` | `2` | Minimum related task records required before a pattern is eligible for draft generation. |
| `min_success_ratio` | `0.7` | Minimum success ratio for a task cluster. Use a value greater than `0` and up to `1`. |
| `cold_path_trigger` | `after_turn` | Runs draft generation `after_turn`, on a `scheduled` cadence, or disables automatic cold-path runs when set to `manual`. There is no user-facing manual trigger yet. Applies only in `draft` and `apply` modes. |
| `cold_path_times` | `[]` | Scheduled run times used when `cold_path_trigger` is `scheduled`, written as `HH:MM` strings. |
Use `observe` first if you want to inspect learning records without generating skill changes. Use `draft` when you want PicoClaw to prepare reviewable improvements. Use `apply` only when you are comfortable letting accepted drafts update workspace skills.
### Web launcher dashboard
**picoclaw-launcher** serves a browser UI that requires password sign-in first. On first run, open `/launcher-setup` to create the dashboard password. Later manual sign-ins use `/launcher-login`.
@ -246,6 +276,69 @@ earlier and broader fallback rules later.
For more complete routing and model-tier examples, see the [Routing Guide](routing-guide.md).
### Agent Tool Allowlist
Per-agent tool declarations live in `AGENT.md` frontmatter, not in `config.json`.
If `tools` is omitted from frontmatter, the agent gets the normal globally enabled tool set. If `tools` is present, PicoClaw registers only the listed runtime tools for that agent.
```md
---
name: Research Agent
description: Specialist for web research and in-depth analysis.
tools: [read_file, write_file, web_search, web_fetch, message]
skills: [deep-research]
mcpServers: [web-index]
---
You are the research agent.
```
Notes:
- This is an allowlist, not a preference hint.
- Tool names are matched against the runtime tool name 1:1.
- Use runtime tool names such as `web_search`, `web_fetch`, `spawn`, `subagent`, `send_file`.
- Tool declarations in `AGENT.md` are used by runtime/tooling, but they are not injected into the discovery prompt.
### Agent Discovery (Automatic)
When an agent has spawnable peers and can call `spawn`, PicoClaw injects a structured agent registry into that agent's system prompt on every turn. No extra `list_agents` tool call is required.
This registry is intended to make delegation concrete and reliable, especially when using `spawn` with a target `agent_id`.
Each entry includes:
| Field | Meaning |
|-------|---------|
| `id` | Stable agent id |
| `name` | Agent identity name from `AGENT.md` frontmatter |
| `description` | Agent identity description from `AGENT.md` frontmatter |
Important behavior:
- The discovery section appears only when the current agent has the `spawn` tool and includes only peer agents it is permitted to spawn via `subagents.allow_agents`.
- The current agent and non-spawnable peers are omitted, so the model does not plan against unavailable agents.
- Discovery is intentionally lightweight. It gives the model only the identity it needs to choose a peer: `id`, `name`, and `description`.
- `config.json` remains the infrastructure layer: workspace, default agent selection, routing, and subagent permissions. Those permissions also gate discovery visibility.
- `AGENT.md` remains the identity layer. Runtime/tool code can still use its `tools`, `skills`, `mcpServers`, and `model` fields when delegation happens.
Example injected shape:
```json
{
"agents": [
{
"id": "research",
"name": "Research Agent",
"description": "Specialist for long-form investigation and web work."
}
]
}
```
In practice, this means a generalist agent can choose a peer based on its role description, then call `spawn` with the peer's `agent_id`. The runtime resolves the rest.
### 🔒 Security Sandbox
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.

View file

@ -67,6 +67,36 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
> **提示:**`AGENT.md``SOUL.md``USER.md``memory/MEMORY.md` 的修改会通过文件修改时间mtime在运行时自动检测。**无需重启 gateway**Agent 将在下一次请求时自动加载最新内容。
### Agent 自进化
`evolution` 配置块控制 PicoClaw 的自进化运行时。启用后Agent 会把已完成的回合记录为学习记录。在更高模式下,它可以聚类重复出现的成功模式、生成技能草稿,并可选择把已接受的草稿应用到工作区技能中。
```json
{
"evolution": {
"enabled": false,
"mode": "observe",
"state_dir": "",
"min_task_count": 2,
"min_success_ratio": 0.7,
"cold_path_trigger": "after_turn",
"cold_path_times": []
}
}
```
| 字段 | 默认值 | 说明 |
|------|--------|------|
| `enabled` | `false` | 启用已完成 Agent 回合的学习记录采集。Heartbeat 回合会被忽略。 |
| `mode` | `observe` | `observe` 只记录数据;`draft` 可生成候选技能草稿;`apply` 可将已接受草稿应用到工作区技能。 |
| `state_dir` | `""` | 自进化状态的可选目录。留空时使用工作区下的默认位置。 |
| `min_task_count` | `2` | 一个模式具备生成草稿资格前所需的最小相关任务记录数。 |
| `min_success_ratio` | `0.7` | 任务聚类所需的最小成功率,取值需大于 `0`,且不超过 `1`。 |
| `cold_path_trigger` | `after_turn` | 草稿生成可在 `after_turn` 后运行、按 `scheduled` 定时运行;设置为 `manual` 时会关闭自动冷路径运行。目前还没有用户可用的手动触发入口。仅在 `draft``apply` 模式下生效。 |
| `cold_path_times` | `[]` | 当 `cold_path_trigger``scheduled` 时使用的运行时间,格式为 `HH:MM` 字符串。 |
如果你只想先检查学习记录,建议从 `observe` 开始。需要生成可审查改进时使用 `draft`。只有在你接受让已通过的草稿更新工作区技能时,才使用 `apply`
### Web 启动器控制台
**picoclaw-launcher** 打开浏览器控制台前需要先使用密码登录。首次启动时打开 `/launcher-setup` 创建 dashboard 登录密码;后续手动登录使用 `/launcher-login`

View file

@ -57,6 +57,14 @@
## 📢 Actualités
2026-05-11 🛒 **LicheeRV-Claw disponible sur AliExpress !** Vous pouvez désormais acheter le LicheeRV-Claw sur [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), ce qui facilite l'essai de PicoClaw sur du matériel RISC-V compact.
<p align="center">
<a href="https://www.aliexpress.com/item/1005006519668532.html">
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
</a>
</p>
2026-03-31 📱 **Support Android !** PicoClaw fonctionne maintenant sur Android ! Téléchargez l'APK sur [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 publiée !** Refonte de l'architecture Agent (SubTurn, Hooks, Steering, EventBus), intégration WeChat/WeCom, renforcement de la sécurité (.security.yml, filtrage des données sensibles), nouveaux providers (AWS Bedrock, Azure, Xiaomi MiMo), et 35 corrections de bugs. PicoClaw a atteint **26K Stars** !
@ -479,7 +487,7 @@ PicoClaw peut effectuer des recherches sur le web pour fournir des informations
| Moteur de recherche | Clé API | Niveau gratuit | Lien |
|--------------------|---------|----------------|------|
| DuckDuckGo | Non requise | Illimité | Fallback intégré |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Requise | 1000 requêtes/jour | IA, optimisé pour le chinois |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Requise | 1500 requêtes/mois (allocation journalière) | IA, optimisé pour le chinois |
| [Tavily](https://tavily.com) | Requise | 1000 requêtes/mois | Optimisé pour les Agents IA |
| [Brave Search](https://brave.com/search/api) | Requise | 2000 requêtes/mois | Rapide et privé |
| [Perplexity](https://www.perplexity.ai) | Requise | Payant | Recherche propulsée par IA |

View file

@ -56,6 +56,14 @@
## 📢 Berita
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Kini Anda dapat membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), sehingga lebih mudah mencoba PicoClaw di hardware RISC-V ringkas.
<p align="center">
<a href="https://www.aliexpress.com/item/1005006519668532.html">
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
</a>
</p>
2026-03-31 📱 **Dukungan Android!** PicoClaw sekarang berjalan di Android! Unduh APK di [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 Dirilis!** Perombakan arsitektur Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keamanan (.security.yml, penyaringan data sensitif), provider baru (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 perbaikan bug. PicoClaw telah mencapai **26K Stars**!
@ -474,7 +482,7 @@ PicoClaw dapat mencari web untuk memberikan informasi terkini. Konfigurasi di `t
| Mesin Pencari | API Key | Tier Gratis | Tautan |
|--------------|---------|-------------|--------|
| DuckDuckGo | Tidak perlu | Tidak terbatas | Fallback bawaan |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1000 kueri/hari | Bertenaga AI, dioptimalkan untuk bahasa Mandarin |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1500 kueri/bulan (alokasi harian) | Bertenaga AI, dioptimalkan untuk bahasa Mandarin |
| [Tavily](https://tavily.com) | Diperlukan | 1000 kueri/bulan | Dioptimalkan untuk AI Agent |
| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 kueri/bulan | Cepat dan privat |
| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Pencarian bertenaga AI |

View file

@ -56,6 +56,14 @@
## 📢 Novità
2026-05-11 🛒 **LicheeRV-Claw disponibile su AliExpress!** Ora puoi acquistare LicheeRV-Claw su [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), rendendo più semplice provare PicoClaw su hardware RISC-V compatto.
<p align="center">
<a href="https://www.aliexpress.com/item/1005006519668532.html">
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
</a>
</p>
2026-03-31 📱 **Supporto Android!** PicoClaw ora funziona su Android! Scarica l'APK su [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 rilasciata!** Revisione dell'architettura Agent (SubTurn, Hooks, Steering, EventBus), integrazione WeChat/WeCom, rafforzamento della sicurezza (.security.yml, filtraggio dati sensibili), nuovi provider (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correzioni di bug. PicoClaw raggiunge **26K Stars**!
@ -474,7 +482,7 @@ PicoClaw può cercare sul web per fornire informazioni aggiornate. Configura in
| Motore di Ricerca | API Key | Piano Gratuito | Link |
|-------------------|---------|----------------|------|
| DuckDuckGo | Non necessaria | Illimitato | Fallback integrato |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Richiesta | 1000 query/giorno | IA, ottimizzato per il cinese |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Richiesta | 1500 query/mese (allocazione giornaliera) | IA, ottimizzato per il cinese |
| [Tavily](https://tavily.com) | Richiesta | 1000 query/mese | Ottimizzato per AI Agent |
| [Brave Search](https://brave.com/search/api) | Richiesta | 2000 query/mese | Veloce e privato |
| [Perplexity](https://www.perplexity.ai) | Richiesta | A pagamento | Ricerca potenziata dall'IA |

View file

@ -56,6 +56,14 @@
## 📢 ニュース
2026-05-11 🛒 **LicheeRV-Claw が AliExpress で購入可能に!** [AliExpress](https://www.aliexpress.com/item/1005006519668532.html) から LicheeRV-Claw を購入できるようになり、コンパクトな RISC-V ハードウェアで PicoClaw を試しやすくなりました。
<p align="center">
<a href="https://www.aliexpress.com/item/1005006519668532.html">
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
</a>
</p>
2026-03-31 📱 **Android サポート!** PicoClawがAndroidで動作APKは[picoclaw.io](https://picoclaw.io/download)からダウンロード
2026-03-25 🚀 **v0.2.4 リリース!** Agent アーキテクチャ全面刷新SubTurn、Hooks、Steering、EventBus、WeChat/WeCom 統合、セキュリティ強化(.security.yml、機密データフィルタリング、新プロバイダーAWS Bedrock、Azure、Xiaomi MiMo、35 件のバグ修正。PicoClaw **26K ⭐** 達成!
@ -475,7 +483,7 @@ PicoClaw は最新情報を提供するために Web を検索できます。`to
| 検索エンジン | API キー | 無料枠 | リンク |
|------------|---------|--------|-------|
| DuckDuckGo | 不要 | 無制限 | 内蔵フォールバック |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必須 | 1000 クエリ/日 | AI 搭載、中国語に最適化 |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必須 | 1500 クエリ/月(日次割り当て) | AI 搭載、中国語に最適化 |
| [Tavily](https://tavily.com) | 必須 | 1000 クエリ/月 | AI Agent 向けに最適化 |
| [Brave Search](https://brave.com/search/api) | 必須 | 2000 クエリ/月 | 高速でプライベート |
| [Perplexity](https://www.perplexity.ai) | 必須 | 有料 | AI 搭載検索 |

View file

@ -56,6 +56,14 @@
## 📢 뉴스
2026-05-11 🛒 **LicheeRV-Claw를 AliExpress에서 구매할 수 있습니다!** 이제 [AliExpress](https://www.aliexpress.com/item/1005006519668532.html)에서 LicheeRV-Claw를 구매해 소형 RISC-V 하드웨어에서 PicoClaw를 더 쉽게 사용해 볼 수 있습니다.
<p align="center">
<a href="https://www.aliexpress.com/item/1005006519668532.html">
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
</a>
</p>
2026-03-31 📱 **Android 지원!** PicoClaw가 이제 Android에서 실행됩니다! APK는 [picoclaw.io](https://picoclaw.io/download)에서 다운로드하세요.
2026-03-25 🚀 **v0.2.4 출시!** 에이전트 아키텍처 전면 개편(SubTurn, Hooks, Steering, EventBus), WeChat/WeCom 통합, 보안 강화(`.security.yml`, 민감 정보 필터링), 새 프로바이더(AWS Bedrock, Azure, Xiaomi MiMo), 그리고 35건의 버그 수정이 포함되었습니다. PicoClaw는 **26K 스타**를 달성했습니다!
@ -480,7 +488,7 @@ PicoClaw는 최신 정보를 제공하기 위해 웹 검색을 수행할 수 있
| 검색 엔진 | API Key | 무료 제공량 | 링크 |
|-----------|---------|-------------|------|
| DuckDuckGo | 불필요 | 무제한 | 내장 백업 검색 |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 필수 | 하루 1000회 쿼리 | AI 기반, 중국 시장 최적화 |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 필수 | 월 1500회 쿼리 (일할 할당) | AI 기반, 중국 시장 최적화 |
| [Tavily](https://tavily.com) | 필수 | 월 1000회 쿼리 | AI 에이전트에 최적화 |
| [Brave Search](https://brave.com/search/api) | 필수 | 월 2000회 쿼리 | 빠르고 프라이빗함 |
| [Perplexity](https://www.perplexity.ai) | 필수 | 유료 | AI 기반 검색 |

View file

@ -56,6 +56,14 @@
## 📢 Berita
2026-05-11 🛒 **LicheeRV-Claw tersedia di AliExpress!** Anda kini boleh membeli LicheeRV-Claw di [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), menjadikannya lebih mudah untuk mencuba PicoClaw pada perkakasan RISC-V yang kompak.
<p align="center">
<a href="https://www.aliexpress.com/item/1005006519668532.html">
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
</a>
</p>
2026-03-31 📱 **Sokongan Android!** PicoClaw sekarang berjalan di Android! Muat turun APK di [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 Dikeluarkan!** Penstrukturan semula seni bina Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keselamatan (.security.yml, penapisan data sensitif), penyedia baharu (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 pembetulan pepijat. PicoClaw mencapai **26K Stars**!
@ -474,7 +482,7 @@ PicoClaw boleh mencari web untuk menyediakan maklumat terkini. Konfigurasikan da
| Enjin Carian | Kunci API | Peringkat Percuma | Pautan |
|-------------|-----------|-------------------|--------|
| DuckDuckGo | Tidak perlu | Tanpa had | Sandaran terbina dalam |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1000 pertanyaan/hari | Dikuasai AI, dioptimumkan untuk China |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1500 pertanyaan/bulan (peruntukan harian) | Dikuasai AI, dioptimumkan untuk China |
| [Tavily](https://tavily.com) | Diperlukan | 1000 pertanyaan/bulan | Dioptimumkan untuk AI Agent |
| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 pertanyaan/bulan | Pantas dan peribadi |
| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Carian dikuasai AI |

View file

@ -56,6 +56,14 @@
## 📢 Novidades
2026-05-11 🛒 **LicheeRV-Claw no AliExpress!** Agora você pode comprar o LicheeRV-Claw no [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), facilitando testar o PicoClaw em hardware RISC-V compacto.
<p align="center">
<a href="https://www.aliexpress.com/item/1005006519668532.html">
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
</a>
</p>
2026-03-31 📱 **Suporte Android!** PicoClaw agora roda no Android! Baixe o APK em [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 Lançada!** Reformulação da arquitetura Agent (SubTurn, Hooks, Steering, EventBus), integração WeChat/WeCom, fortalecimento de segurança (.security.yml, filtragem de dados sensíveis), novos providers (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correções de bugs. O PicoClaw atingiu **26K Stars**!
@ -475,7 +483,7 @@ O PicoClaw pode pesquisar na web para fornecer informações atualizadas. Config
| Motor de Busca | API Key | Nível Gratuito | Link |
|----------------|---------|----------------|------|
| DuckDuckGo | Não necessária | Ilimitado | Fallback integrado |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Obrigatória | 1000 consultas/dia | IA, otimizado para chinês |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Obrigatória | 1500 consultas/mês (alocação diária) | IA, otimizado para chinês |
| [Tavily](https://tavily.com) | Obrigatória | 1000 consultas/mês | Otimizado para AI Agents |
| [Brave Search](https://brave.com/search/api) | Obrigatória | 2000 consultas/mês | Rápido e privado |
| [Perplexity](https://www.perplexity.ai) | Obrigatória | Pago | Busca com IA |

View file

@ -56,6 +56,14 @@
## 📢 Tin tức
2026-05-11 🛒 **LicheeRV-Claw đã có trên AliExpress!** Bạn hiện có thể mua LicheeRV-Claw trên [AliExpress](https://www.aliexpress.com/item/1005006519668532.html), giúp việc thử PicoClaw trên phần cứng RISC-V nhỏ gọn dễ dàng hơn.
<p align="center">
<a href="https://www.aliexpress.com/item/1005006519668532.html">
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on AliExpress" width="520">
</a>
</p>
2026-03-31 📱 **Hỗ trợ Android!** PicoClaw giờ chạy trên Android! Tải APK tại [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 đã phát hành!** Tái cấu trúc kiến trúc Agent (SubTurn, Hooks, Steering, EventBus), tích hợp WeChat/WeCom, tăng cường bảo mật (.security.yml, lọc dữ liệu nhạy cảm), provider mới (AWS Bedrock, Azure, Xiaomi MiMo) và 35 bản vá lỗi. PicoClaw đã đạt **26K Stars**!
@ -475,7 +483,7 @@ PicoClaw có thể tìm kiếm web để cung cấp thông tin cập nhật. C
| Công cụ Tìm kiếm | API Key | Gói miễn phí | Liên kết |
|------------------|---------|--------------|----------|
| DuckDuckGo | Không cần | Không giới hạn | Dự phòng tích hợp sẵn |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Bắt buộc | 1000 truy vấn/ngày | AI, tối ưu cho tiếng Trung |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Bắt buộc | 1500 truy vấn/tháng (phân bổ hàng ngày) | AI, tối ưu cho tiếng Trung |
| [Tavily](https://tavily.com) | Bắt buộc | 1000 truy vấn/tháng | Tối ưu cho AI Agent |
| [Brave Search](https://brave.com/search/api) | Bắt buộc | 2000 truy vấn/tháng | Nhanh và riêng tư |
| [Perplexity](https://www.perplexity.ai) | Bắt buộc | Trả phí | Tìm kiếm hỗ trợ AI |

View file

@ -56,6 +56,14 @@
## 📢 新闻
2026-05-11 🛒 **LicheeRV-Claw 已上架淘宝!** 现在可以在 [淘宝](https://item.taobao.com/item.htm?abbucket=20&id=764939520376) 购买 LicheeRV-Claw更方便地在小型 RISC-V 硬件上体验 PicoClaw。
<p align="center">
<a href="https://item.taobao.com/item.htm?abbucket=20&id=764939520376">
<img src="../../assets/licheerv-claw.jpg" alt="LicheeRV-Claw on Taobao" width="520">
</a>
</p>
2026-03-31 📱 **Android 支持!** PicoClaw 现可在 Android 上运行APK 下载地址:[picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 发布!** Agent 架构全面重构SubTurn、Hook、Steering、EventBus、微信/企业微信深度集成、安全体系升级(.security.yml、敏感数据过滤、新增 ProviderAWS Bedrock、Azure、小米 MiMo以及 35 项 Bug 修复。PicoClaw 已达 **26K ⭐**
@ -144,9 +152,9 @@ _*近期版本因快速合并 PR 可能占用 1020MB资源优化已列入
PicoClaw 几乎可以部署在任何 Linux 设备上!
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控
- $9.9 [LicheeRV-Nano](https://item.taobao.com/item.htm?id=764939520376) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手
- $30~50 [NanoKVM](https://item.taobao.com/item.htm?id=811206560480),或 $100 [NanoKVM-Pro](https://item.taobao.com/item.htm?id=994419942411),用于自动化服务器运维
- $50 [MaixCAM](https://item.taobao.com/item.htm?id=784724795837) 或 $100 [MaixCAM2](https://item.taobao.com/item.htm?id=1050380368975),用于智能监控
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
@ -475,7 +483,7 @@ PicoClaw 可以搜索网络以提供最新信息。在 `tools.web` 中配置:
| 搜索引擎 | API Key | 免费额度 | 链接 |
|---------|---------|---------|------|
| [百度搜索](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必填 | 1000 次/天 | AI 搜索,国内首选 |
| [百度搜索](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | 必填 | 1500 次/月(按天发放) | AI 搜索,国内首选 |
| [Tavily](https://tavily.com) | 必填 | 1000 次/月 | 专为 AI Agent 优化 |
| [GLM Search](https://open.bigmodel.cn/) | 必填 | 视情况 | 智谱网络搜索 |
| DuckDuckGo | 无需 | 无限制 | 内置备用(国内访问困难) |

View file

@ -282,4 +282,3 @@ New config (version 3):
- Check that the migration doesn't overwrite values with defaults unnecessarily
- Review the conversion logic in the loader functions
- Check the auto-backup files (e.g., `config.json.20260330.bak`) to recover original data

View file

@ -89,6 +89,13 @@ channels:
nickserv_password: "your-irc-nickserv-password"
sasl_password: "your-irc-sasl-password"
# Channel Settings (nested format for channels that use settings block)
channel_list:
mqtt:
settings:
username: "your-mqtt-username"
password: "your-mqtt-password"
# Web Tool API Keys
web:
brave:
@ -226,6 +233,19 @@ channels:
- `channels.feishu.app_secret``config.channels.feishu.app_secret`
- etc.
Channels that use a `settings` block (e.g. MQTT) use the `channel_list` key instead:
```yaml
channel_list:
mqtt:
settings:
username: "value"
password: "value"
```
- `channel_list.mqtt.settings.username``config.channel_list.mqtt.settings.username`
- `channel_list.mqtt.settings.password``config.channel_list.mqtt.settings.password`
### Web Tools
**Brave, Tavily, Perplexity:**

10
go.mod
View file

@ -1,9 +1,9 @@
module github.com/sipeed/picoclaw
go 1.25.9
go 1.25.10
require (
fyne.io/systray v1.12.0
fyne.io/systray v1.12.1
github.com/SevereCloud/vksdk/v3 v3.3.1
github.com/adhocore/gronx v1.19.6
github.com/anthropics/anthropic-sdk-go v1.26.0
@ -21,7 +21,8 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/h2non/filetype v1.1.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
github.com/larksuite/oapi-sdk-go/v3 v3.6.1
github.com/line/line-bot-sdk-go/v8 v8.19.0
github.com/mdp/qrterminal/v3 v3.2.1
github.com/minio/selfupdate v0.6.0
github.com/modelcontextprotocol/go-sdk v1.5.0
@ -75,6 +76,7 @@ require (
github.com/coder/websocket v1.8.14 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@ -118,7 +120,7 @@ require (
github.com/github/copilot-sdk/go v0.2.0
github.com/go-resty/resty/v2 v2.17.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/jsonschema-go v0.4.2
github.com/google/jsonschema-go v0.4.3
github.com/grbit/go-json v0.11.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect

16
go.sum
View file

@ -3,8 +3,8 @@ aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM=
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ=
fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/SevereCloud/vksdk/v3 v3.3.1 h1:O86zsp5LQnHE+O5acvuXM/s6S1LyxzVTkF6+Lup0Jyg=
@ -95,6 +95,8 @@ github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
github.com/ergochat/irc-go v0.6.0 h1:Y0AGV76aeihJfCtLaQh+OyJKFiKGrYC0VTkeMZ6XW28=
@ -142,8 +144,8 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -177,8 +179,10 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/larksuite/oapi-sdk-go/v3 v3.6.1 h1:vAdu+sX9yXNkKnKnYQeIv6yBkjP37Q1JEJHmMa2eCjQ=
github.com/larksuite/oapi-sdk-go/v3 v3.6.1/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/line/line-bot-sdk-go/v8 v8.19.0 h1:5FD/1SprRZ8Y0FiUI6syYiBewOs0ak2tuUBMYN0wzE4=
github.com/line/line-bot-sdk-go/v8 v8.19.0/go.mod h1:AeSRUuu7WGgveGDJb6DyKyFUOst2UB2aF6LO2cQeuXs=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=

View file

@ -55,9 +55,11 @@ type AgentLoop struct {
transcriber asr.Transcriber
cmdRegistry *commands.Registry
mcp mcpRuntime
evolution *evolutionBridge
hookRuntime hookRuntime
steering *steeringQueue
pendingSkills sync.Map
pendingStops sync.Map
mu sync.RWMutex
// workerSem limits concurrent turn processing workers.
@ -177,6 +179,12 @@ func (al *AgentLoop) Run(ctx context.Context) error {
phase: TurnPhaseSetup,
}
if _, loaded := al.activeTurnStates.LoadOrStore(sessionKey, placeholder); loaded {
if al.tryHandleStopCommand(ctx, msg, sessionKey) {
continue
}
msg = al.prepareInboundMessageForAgent(ctx, msg)
// Another turn is already active (or reserved) for this session — enqueue
if err := al.enqueueSteeringMessage(sessionKey, agentID, providers.Message{
Role: "user",
@ -240,6 +248,24 @@ func (al *AgentLoop) Run(ctx context.Context) error {
defer al.channelManager.InvokeTypingStop(m.Channel, m.ChatID)
}
if al.takePendingStop(sessionKey) {
al.activeTurnStates.Delete(sessionKey)
target := &continuationTarget{
SessionKey: sessionKey,
Channel: m.Channel,
ChatID: m.ChatID,
}
continued, continueErr := al.drainQueuedSteeringContinuations(ctx, target)
if continueErr != nil {
al.maybePublishError(ctx, m.Channel, m.ChatID, sessionKey, continueErr)
return
}
if continued != "" {
al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, continued)
}
return
}
al.runTurnWithSteering(ctx, m)
}(msg)
@ -285,6 +311,15 @@ func (al *AgentLoop) Close() {
})
}
}
evolution := al.currentEvolutionBridge()
if evolution != nil {
if err := evolution.Close(); err != nil {
logger.ErrorCF("agent", "Failed to close evolution bridge",
map[string]any{
"error": err.Error(),
})
}
}
al.GetRegistry().Close()
if al.hooks != nil {
@ -369,14 +404,29 @@ func (al *AgentLoop) ReloadProviderAndConfig(
// Ensure shared tools are re-registered on the new registry
registerSharedTools(al, cfg, al.bus, registry, provider)
newEvolution, evolutionErr := newEvolutionBridge(registry, cfg, provider)
if evolutionErr != nil {
logger.WarnCF("agent", "Failed to reinitialize evolution bridge during reload",
map[string]any{"error": evolutionErr.Error()})
}
if newEvolution != nil {
newEvolution.setCurrentCheck(al.isCurrentEvolutionBridge)
if err := newEvolution.subscribeRuntimeEvents(al.runtimeEvents.Channel()); err != nil {
logger.WarnCF("agent", "Failed to subscribe reloaded evolution bridge to runtime events",
map[string]any{"error": err.Error()})
}
}
// Atomically swap the config and registry under write lock
// This ensures readers see a consistent pair
al.mu.Lock()
oldRegistry := al.registry
oldEvolution := al.evolution
// Store new values
al.cfg = cfg
al.registry = registry
al.evolution = newEvolution
// Also update fallback chain with new config; rebuild rate limiter registry.
newRL := providers.NewRateLimiterRegistry()
@ -404,6 +454,12 @@ func (al *AgentLoop) ReloadProviderAndConfig(
map[string]any{"error": err.Error()})
}
}
if oldEvolution != nil {
if err := oldEvolution.Close(); err != nil {
logger.WarnCF("agent", "Failed to close previous evolution bridge during reload",
map[string]any{"error": err.Error()})
}
}
if err := al.ensureMCPInitialized(ctx); err != nil {
logger.WarnCF("agent", "MCP failed to reinitialize after reload",
map[string]any{"error": err.Error()})

View file

@ -274,6 +274,12 @@ func (al *AgentLoop) buildCommandsRuntime(
return nil
},
}
rt.StopActiveTurn = func() (commands.StopResult, error) {
if opts == nil {
return commands.StopResult{}, fmt.Errorf("process options not available")
}
return al.stopActiveTurnForSession(opts.Dispatch.SessionKey)
}
if agent != nil && agent.ContextBuilder != nil {
rt.ListSkillNames = agent.ContextBuilder.ListSkillNames
}

View file

@ -47,9 +47,40 @@ func (al *AgentLoop) emitEvent(kind runtimeevents.Kind, meta HookMeta, payload a
return
}
deliveredToEvolution := false
if kind == runtimeevents.KindAgentTurnEnd {
evolution := al.currentEvolutionBridge()
if evolution != nil {
deliveredToEvolution = evolution.handleRuntimeTurnEnd(evt)
}
}
if deliveredToEvolution {
if evt.Attrs == nil {
evt.Attrs = make(map[string]any, 1)
}
evt.Attrs[evolutionDirectDeliveryAttr] = true
}
al.publishRuntimeEvent(evt)
}
func (al *AgentLoop) currentEvolutionBridge() *evolutionBridge {
if al == nil {
return nil
}
al.mu.RLock()
defer al.mu.RUnlock()
return al.evolution
}
func (al *AgentLoop) isCurrentEvolutionBridge(bridge *evolutionBridge) bool {
if al == nil || bridge == nil {
return false
}
al.mu.RLock()
defer al.mu.RUnlock()
return al.evolution == bridge
}
// MountHook registers an in-process hook on the agent loop.
func (al *AgentLoop) MountHook(reg HookRegistration) error {
if al == nil || al.hooks == nil {

View file

@ -49,6 +49,13 @@ func NewAgentLoop(
stateManager = state.NewManager(defaultAgent.Workspace)
}
bridge, err := newEvolutionBridge(registry, cfg, provider)
if err != nil {
logger.WarnCF("agent", "Failed to initialize evolution bridge", map[string]any{
"error": err.Error(),
})
}
// Determine worker pool size from config (default: 1 = sequential)
workerPoolSize := cfg.Agents.Defaults.MaxParallelTurns
if workerPoolSize <= 0 {
@ -62,6 +69,7 @@ func NewAgentLoop(
state: stateManager,
fallback: fallbackChain,
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
evolution: bridge,
steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)),
workerSem: make(chan struct{}, workerPoolSize),
ownsRuntimeEvents: true,
@ -75,6 +83,14 @@ func NewAgentLoop(
al.runtimeEvents = runtimeevents.NewBus()
al.ownsRuntimeEvents = true
}
if bridge != nil {
bridge.setCurrentCheck(al.isCurrentEvolutionBridge)
if err := bridge.subscribeRuntimeEvents(al.runtimeEvents.Channel()); err != nil {
logger.WarnCF("agent", "Failed to subscribe evolution bridge to runtime events", map[string]any{
"error": err.Error(),
})
}
}
al.refreshRuntimeEventLogger(cfg)
al.providerFactory = providers.CreateProviderFromConfig
al.hooks = NewHookManager(al.runtimeEvents.Channel())
@ -337,5 +353,22 @@ func registerSharedTools(
} else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") {
logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil)
}
// Register delegate tool for multi-agent setups.
// Auto-enabled when multiple agents exist. Delegation uses the SubTurn
// mechanism directly (not SubagentManager) and is independent of the
// subagent tool.
if len(registry.ListAgentIDs()) > 1 {
delegateTool := tools.NewDelegateTool()
delegateTool.SetSpawner(NewSubTurnSpawner(al))
currentAgentID := agentID
delegateTool.SetSelfAgentID(currentAgentID)
delegateTool.SetAllowlistChecker(func(targetAgentID string) bool {
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
})
agent.Tools.Register(delegateTool)
}
warnOnUnknownAgentToolDeclarations(agentID, agent.Workspace, agent.Definition, agent.Tools)
}
}

View file

@ -85,8 +85,18 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
return nil
}
mcpCfg := filterMCPConfigServers(al.cfg.Tools.MCP, al.registry.allowedMCPServers())
if mcpCfg.Servers == nil || len(mcpCfg.Servers) == 0 {
logger.InfoCF(
"agent",
"No MCP servers selected after applying per-agent mcpServers allowlists",
nil,
)
return nil
}
findValidServer := false
for _, serverCfg := range al.cfg.Tools.MCP.Servers {
for _, serverCfg := range mcpCfg.Servers {
if serverCfg.Enabled {
findValidServer = true
}
@ -105,7 +115,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
workspacePath = defaultAgent.Workspace
}
if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil {
if err := mcpManager.LoadFromMCPConfig(ctx, mcpCfg, workspacePath); err != nil {
al.mcp.setInitErr(fmt.Errorf("failed to load MCP servers: %w", err))
logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available",
map[string]any{
@ -132,27 +142,9 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
// Determine whether this server's tools should be deferred (hidden).
// Per-server "deferred" field takes precedence over the global Discovery.Enabled.
serverCfg := al.cfg.Tools.MCP.Servers[serverName]
serverCfg := mcpCfg.Servers[serverName]
registerAsHidden := serverIsDeferred(al.cfg.Tools.MCP.Discovery.Enabled, serverCfg)
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok || agent.ContextBuilder == nil {
continue
}
if err := agent.ContextBuilder.RegisterPromptContributor(mcpServerPromptContributor{
serverName: serverName,
toolCount: len(conn.Tools),
deferred: registerAsHidden,
}); err != nil {
logger.WarnCF("agent", "Failed to register MCP prompt contributor",
map[string]any{
"agent_id": agentID,
"server": serverName,
"error": err.Error(),
})
}
}
registeredToolsByAgent := make(map[string]map[string]struct{}, len(agentIDs))
for _, tool := range conn.Tools {
for _, agentID := range agentIDs {
@ -160,8 +152,18 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
if !ok {
continue
}
if !agent.AllowsMCPServer(serverName) {
logger.DebugCF("agent", "Skipped MCP tool registration by agent mcpServers allowlist",
map[string]any{
"agent_id": agentID,
"server": serverName,
"tool": tool.Name,
})
continue
}
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
toolName := mcpTool.Name()
mcpTool.SetWorkspace(agent.Workspace)
mcpTool.SetMaxInlineTextRunes(al.cfg.Tools.MCP.GetMaxInlineTextChars())
mcpTool.SetEventPublisher(al.runtimeEvents)
@ -171,18 +173,36 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
} else {
agent.Tools.Register(mcpTool)
}
if !toolRegistryIncludes(agent.Tools, toolName) {
continue
}
recordRegisteredMCPTool(registeredToolsByAgent, agentID, toolName)
totalRegistrations++
logger.DebugCF("agent", "Registered MCP tool",
map[string]any{
"agent_id": agentID,
"server": serverName,
"tool": tool.Name,
"name": mcpTool.Name(),
"name": toolName,
"deferred": registerAsHidden,
})
}
}
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
registerMCPServerPromptContributor(
agentID,
agent,
serverName,
len(registeredToolsByAgent[agentID]),
registerAsHidden,
)
}
}
logger.InfoCF("agent", "MCP tools registered successfully",
map[string]any{
@ -230,6 +250,9 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
if !ok {
continue
}
if !agentHasDiscoverableMCPServers(al.cfg, agent.MCPServerAllowlist) {
continue
}
if useRegex {
agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults))
@ -246,6 +269,89 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
return al.mcp.getInitErr()
}
func registerMCPServerPromptContributor(
agentID string,
agent *AgentInstance,
serverName string,
toolCount int,
registerAsHidden bool,
) {
if agent == nil || agent.ContextBuilder == nil || toolCount <= 0 {
return
}
if err := agent.ContextBuilder.RegisterPromptContributor(mcpServerPromptContributor{
serverName: serverName,
toolCount: toolCount,
deferred: registerAsHidden,
}); err != nil {
logger.WarnCF("agent", "Failed to register MCP prompt contributor",
map[string]any{
"agent_id": agentID,
"server": serverName,
"error": err.Error(),
})
}
}
func recordRegisteredMCPTool(
registeredToolsByAgent map[string]map[string]struct{},
agentID, toolName string,
) {
if registeredToolsByAgent[agentID] == nil {
registeredToolsByAgent[agentID] = make(map[string]struct{})
}
registeredToolsByAgent[agentID][toolName] = struct{}{}
}
func toolRegistryIncludes(registry *tools.ToolRegistry, name string) bool {
if registry == nil {
return false
}
return registry.HasRegistered(name)
}
func filterMCPConfigServers(
mcpCfg config.MCPConfig,
allowed map[string]struct{},
) config.MCPConfig {
if allowed == nil {
return mcpCfg
}
filtered := mcpCfg
filtered.Servers = make(map[string]config.MCPServerConfig)
normalizedAllowed := make(map[string]struct{}, len(allowed))
for serverName := range allowed {
name := normalizeMCPServerName(serverName)
if name == "" {
continue
}
normalizedAllowed[name] = struct{}{}
}
for serverName, serverCfg := range mcpCfg.Servers {
if _, ok := normalizedAllowed[normalizeMCPServerName(serverName)]; ok {
filtered.Servers[serverName] = serverCfg
}
}
return filtered
}
func agentHasDiscoverableMCPServers(cfg *config.Config, allowed map[string]struct{}) bool {
if cfg == nil || !cfg.Tools.MCP.Enabled || !cfg.Tools.MCP.Discovery.Enabled {
return false
}
filtered := filterMCPConfigServers(cfg.Tools.MCP, allowed)
for _, serverCfg := range filtered.Servers {
if serverCfg.Enabled && serverIsDeferred(cfg.Tools.MCP.Discovery.Enabled, serverCfg) {
return true
}
}
return false
}
// serverIsDeferred reports whether an MCP server's tools should be registered
// as hidden (deferred/discovery mode).
//

View file

@ -14,6 +14,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/mcp"
agenttools "github.com/sipeed/picoclaw/pkg/tools"
)
func boolPtr(b bool) *bool { return &b }
@ -135,6 +136,139 @@ func TestServerIsDeferred(t *testing.T) {
}
}
func TestRegisterMCPServerPromptContributorUsesActualRegisteredToolCount(t *testing.T) {
cb := NewContextBuilder(t.TempDir())
agent := &AgentInstance{ContextBuilder: cb}
registerMCPServerPromptContributor("research", agent, "github", 0, false)
messages := cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"})
if prompt := messages[0].Content; strings.Contains(prompt, "MCP server `github`") {
t.Fatalf("expected no MCP prompt when no tools were registered, got %q", prompt)
}
registerMCPServerPromptContributor("research", agent, "github", 2, false)
messages = cb.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"})
prompt := messages[0].Content
if !strings.Contains(prompt, "MCP server `github` is connected") {
t.Fatalf("expected MCP prompt for registered tools, got %q", prompt)
}
if !strings.Contains(prompt, "It contributes 2 tool(s)") {
t.Fatalf("expected actual registered tool count in prompt, got %q", prompt)
}
}
func TestToolRegistryIncludesReportsOnlyRegisteredTools(t *testing.T) {
registry := agenttools.NewToolRegistry()
registry.SetAllowlist([]string{"mcp_github_search"})
registry.RegisterHidden(&allowlistTestTool{name: "mcp_github_search"})
registry.RegisterHidden(&allowlistTestTool{name: "mcp_github_create_issue"})
if !toolRegistryIncludes(registry, "mcp_github_search") {
t.Fatal("expected hidden registered MCP tool to be included")
}
if toolRegistryIncludes(registry, "mcp_github_create_issue") {
t.Fatal("blocked MCP tool should not be included")
}
}
func TestFilterMCPConfigServersCaseInsensitivePreservesOriginalKeys(t *testing.T) {
mcpCfg := config.MCPConfig{
Servers: map[string]config.MCPServerConfig{
"GitHub": {Enabled: true},
"filesystem": {Enabled: true},
"Slack": {Enabled: true},
},
}
allowed := map[string]struct{}{
"github": {},
"FILESYSTEM": {},
}
filtered := filterMCPConfigServers(mcpCfg, allowed)
if len(filtered.Servers) != 2 {
t.Fatalf("filtered.Servers = %v, want 2 entries", filtered.Servers)
}
if _, ok := filtered.Servers["GitHub"]; !ok {
t.Fatal("expected original GitHub config key to be preserved")
}
if _, ok := filtered.Servers["filesystem"]; !ok {
t.Fatal("expected filesystem config key to be preserved")
}
if _, ok := filtered.Servers["github"]; ok {
t.Fatal("did not expect normalized github key to replace original config key")
}
if _, ok := filtered.Servers["Slack"]; ok {
t.Fatal("did not expect unallowed Slack server")
}
}
func TestAgentHasDiscoverableMCPServers(t *testing.T) {
deferredFalse := false
cfg := &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Discovery: config.ToolDiscoveryConfig{
Enabled: true,
UseBM25: true,
UseRegex: false,
},
Servers: map[string]config.MCPServerConfig{
"github": {Enabled: true},
"filesystem": {Enabled: true, Deferred: &deferredFalse},
},
},
},
}
tests := []struct {
name string
allowed map[string]struct{}
want bool
}{
{
name: "nil allowlist includes discoverable enabled server",
want: true,
},
{
name: "empty allowlist denies all servers",
allowed: map[string]struct{}{},
want: false,
},
{
name: "selected server discoverable",
allowed: map[string]struct{}{
"github": {},
},
want: true,
},
{
name: "selected server opted out of discovery",
allowed: map[string]struct{}{
"filesystem": {},
},
want: false,
},
{
name: "unknown allowlist server matches nothing",
allowed: map[string]struct{}{
"slack": {},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := agentHasDiscoverableMCPServers(cfg, tt.allowed); got != tt.want {
t.Fatalf("agentHasDiscoverableMCPServers() = %v, want %v", got, tt.want)
}
})
}
}
func TestEnsureMCPInitialized_LoadFailureSetsInitErr(t *testing.T) {
al, cfg, _, _, cleanup := newTestAgentLoop(t)
defer cleanup()

View file

@ -102,9 +102,27 @@ func (al *AgentLoop) ProcessHeartbeat(
})
}
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
func (al *AgentLoop) prepareInboundMessageForAgent(
ctx context.Context,
msg bus.InboundMessage,
) bus.InboundMessage {
msg = bus.NormalizeInboundMessage(msg)
var hadAudio bool
msg, hadAudio = al.transcribeAudioInMessage(ctx, msg)
// For audio messages the placeholder was deferred by the channel.
// Now that transcription (and optional feedback) is done, send it.
if hadAudio && al.channelManager != nil {
al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID)
}
return msg
}
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
msg = al.prepareInboundMessageForAgent(ctx, msg)
// Add message preview to log (show full content for error messages)
var logContent string
if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") {
@ -123,15 +141,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
},
)
var hadAudio bool
msg, hadAudio = al.transcribeAudioInMessage(ctx, msg)
// For audio messages the placeholder was deferred by the channel.
// Now that transcription (and optional feedback) is done, send it.
if hadAudio && al.channelManager != nil {
al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID)
}
// Route system messages to processSystemMessage
if msg.Channel == "system" {
return al.processSystemMessage(ctx, msg)

View file

@ -56,6 +56,16 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
}
if alreadySentToSameChat {
if al.channelManager != nil && channel != "" && chatID != "" {
dismissCtx, dismissCancel := context.WithTimeout(ctx, 5*time.Second)
al.channelManager.DismissToolFeedback(
dismissCtx,
channel,
chatID,
nil,
)
dismissCancel()
}
logger.DebugCF(
"agent",
"Skipped outbound (message tool already sent to same chat)",

View file

@ -44,11 +44,36 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb
return
}
// Drain steering queue using existing Continue mechanism
continued, continueErr := al.drainQueuedSteeringContinuations(ctx, target)
if continueErr != nil {
logger.WarnCF("agent", "Failed to continue queued steering",
map[string]any{
"channel": target.Channel,
"chat_id": target.ChatID,
"error": continueErr.Error(),
})
} else if continued != "" {
finalResponse = continued
}
// Publish final response
if finalResponse != "" {
al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse)
}
}
func (al *AgentLoop) drainQueuedSteeringContinuations(
ctx context.Context,
target *continuationTarget,
) (string, error) {
if target == nil {
return "", nil
}
finalResponse := ""
for al.pendingSteeringCountForScope(target.SessionKey) > 0 {
// Check for context cancellation between iterations
if ctx.Err() != nil {
return
if err := ctx.Err(); err != nil {
return finalResponse, err
}
logger.InfoCF("agent", "Continuing queued steering after turn end",
@ -61,13 +86,7 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb
continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID)
if continueErr != nil {
logger.WarnCF("agent", "Failed to continue queued steering",
map[string]any{
"channel": target.Channel,
"chat_id": target.ChatID,
"error": continueErr.Error(),
})
break
return finalResponse, continueErr
}
if continued == "" {
break
@ -75,10 +94,7 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb
finalResponse = continued
}
// Publish final response
if finalResponse != "" {
al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse)
}
return finalResponse, nil
}
func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, string, bool) {

122
pkg/agent/agent_stop.go Normal file
View file

@ -0,0 +1,122 @@
package agent
import (
"context"
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/commands"
)
func (al *AgentLoop) tryHandleStopCommand(
ctx context.Context,
msg bus.InboundMessage,
sessionKey string,
) bool {
cmdName, ok := commands.CommandName(msg.Content)
if !ok || cmdName != "stop" {
return false
}
result, err := al.stopActiveTurnForSession(sessionKey)
// This function is only called when loaded=true (another turn already
// claimed this session). If stopActiveTurnForSession found a pending
// placeholder but didn't stop it, that placeholder belongs to the other
// message's worker which hasn't started yet — arm a pending stop so the
// worker will bail when it checks before running.
if err == nil && !result.Stopped {
if ts := al.getActiveTurnState(sessionKey); ts != nil {
snap := ts.snapshot()
if strings.HasPrefix(snap.TurnID, pendingTurnPrefix) {
al.markPendingStop(sessionKey)
result.Stopped = true
}
}
}
reply := commands.FormatStopReply(result)
if err != nil {
reply = "Failed to stop task: " + err.Error()
}
if al.channelManager != nil {
al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID)
}
al.resetMessageToolRound(sessionKey)
al.PublishResponseIfNeeded(ctx, msg.Channel, msg.ChatID, sessionKey, reply)
return true
}
func (al *AgentLoop) stopActiveTurnForSession(sessionKey string) (commands.StopResult, error) {
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey == "" {
return commands.StopResult{}, fmt.Errorf("session key is required")
}
result := commands.StopResult{}
cleared := al.clearSteeringMessagesForScope(sessionKey)
al.clearPendingSkills(sessionKey)
ts := al.getActiveTurnState(sessionKey)
if ts == nil {
result.Stopped = cleared > 0
return result, nil
}
snap := ts.snapshot()
result.TaskName = snap.UserMessage
if strings.HasPrefix(snap.TurnID, pendingTurnPrefix) {
// A pending placeholder means this session is either idle (our own
// placeholder from the /stop command) or another message is queued but
// hasn't started yet. In both cases, we don't arm a pending stop here;
// the caller (tryHandleStopCommand) handles the "another message queued"
// case explicitly, since it knows loaded=true.
return result, nil
}
if err := al.HardAbort(sessionKey); err != nil {
if al.getActiveTurnState(sessionKey) == nil {
result.Stopped = cleared > 0
return result, nil
}
return commands.StopResult{}, err
}
result.Stopped = true
return result, nil
}
func (al *AgentLoop) markPendingStop(sessionKey string) {
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey == "" {
return
}
al.pendingStops.Store(sessionKey, struct{}{})
}
func (al *AgentLoop) takePendingStop(sessionKey string) bool {
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey == "" {
return false
}
_, ok := al.pendingStops.LoadAndDelete(sessionKey)
return ok
}
func (al *AgentLoop) resetMessageToolRound(sessionKey string) {
if strings.TrimSpace(sessionKey) == "" {
return
}
if registry := al.GetRegistry(); registry != nil {
if agent := registry.GetDefaultAgent(); agent != nil {
if tool, ok := agent.Tools.Get("message"); ok {
if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok {
resetter.ResetSentInRound(sessionKey)
}
}
}
}
}

View file

@ -57,6 +57,38 @@ func (f *fakeMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaM
return nil, nil
}
type recordingChannelManager struct {
dismissed []string
}
func (m *recordingChannelManager) GetChannel(name string) (channels.Channel, bool) {
return nil, false
}
func (m *recordingChannelManager) GetEnabledChannels() []string {
return nil
}
func (m *recordingChannelManager) InvokeTypingStop(channel, chatID string) {}
func (m *recordingChannelManager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error {
return nil
}
func (m *recordingChannelManager) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
return nil
}
func (m *recordingChannelManager) SendPlaceholder(ctx context.Context, channel, chatID string) bool {
return false
}
func (m *recordingChannelManager) DismissToolFeedback(
ctx context.Context, channel, chatID string, outboundCtx *bus.InboundContext,
) {
m.dismissed = append(m.dismissed, fmt.Sprintf("%s:%s", channel, chatID))
}
func newStartedTestChannelManager(
t *testing.T,
msgBus *bus.MessageBus,
@ -214,6 +246,44 @@ func TestNewAgentLoop_DoesNotRegisterWebSearchTool_WhenNoReadyProviders(t *testi
}
}
func TestPublishResponseIfNeeded_DismissesToolFeedbackWhenMessageToolAlreadySent(t *testing.T) {
al, msgBus, provider, sessions, cleanup := newTestAgentLoop(t)
defer cleanup()
_ = msgBus
_ = provider
_ = sessions
cm := &recordingChannelManager{}
al.channelManager = cm
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("expected default agent")
}
mt := tools.NewMessageTool()
mt.SetSendCallback(func(ctx context.Context, channel, chatID, content, replyToMessageID string) error {
return nil
})
defaultAgent.Tools.Register(mt)
result := mt.Execute(
tools.WithToolSessionContext(context.Background(), "main", "session-1", nil),
map[string]any{
"content": "ack",
"channel": "telegram",
"chat_id": "-100123",
},
)
if result == nil || result.IsError {
t.Fatalf("message tool execute failed: %+v", result)
}
al.PublishResponseIfNeeded(context.Background(), "telegram", "-100123", "session-1", "final reply")
if got := cm.dismissed; len(got) != 1 || got[0] != "telegram:-100123" {
t.Fatalf("dismissed = %v, want [telegram:-100123]", got)
}
}
func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {

View file

@ -27,6 +27,7 @@ type ContextBuilder struct {
memory *MemoryStore
splitOnMarker bool
skillCatalogCfg config.SkillCatalogConfig
agentDiscovery func(agentID string) []AgentDescriptor
promptRegistry *PromptRegistry
// Cache for system prompt to avoid rebuilding on every call.
@ -72,6 +73,24 @@ func (cb *ContextBuilder) WithSkillCatalogConfig(cfg config.SkillCatalogConfig)
return cb
}
func (cb *ContextBuilder) WithAgentDiscovery(
agentID string,
discover func(agentID string) []AgentDescriptor,
) *ContextBuilder {
cb.agentDiscovery = discover
if discover != nil {
if err := cb.RegisterPromptContributor(agentDiscoveryPromptContributor{
agentID: agentID,
discover: discover,
}); err != nil {
logger.WarnCF("agent", "Failed to register agent discovery prompt contributor", map[string]any{
"error": err.Error(),
})
}
}
return cb
}
func getGlobalConfigDir() string {
return config.GetHome()
}
@ -619,7 +638,9 @@ func formatCurrentSenderLine(senderID, senderDisplayName string) string {
}
}
func (cb *ContextBuilder) buildDynamicContext(channel, chatID, senderID, senderDisplayName string) string {
func (cb *ContextBuilder) buildDynamicContext(
channel, chatID, senderID, senderDisplayName string,
) string {
now := time.Now().Format("2006-01-02 15:04 (Monday)")
rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
@ -879,7 +900,11 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
case "assistant":
if len(msg.ToolCalls) > 0 {
if len(sanitized) == 0 {
logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{})
logger.DebugCF(
"agent",
"Dropping assistant tool-call turn at history start",
map[string]any{},
)
continue
}
prev := sanitized[len(sanitized)-1]
@ -1024,10 +1049,28 @@ func (cb *ContextBuilder) AddAssistantMessage(
}
func (cb *ContextBuilder) buildActiveSkillsContext(skillNames []string) string {
if cb.skillsLoader == nil || len(skillNames) == 0 {
ordered := cb.ResolveActiveSkillsForContext(skillNames)
if len(ordered) == 0 {
return ""
}
content := cb.skillsLoader.LoadSkillsForContext(ordered)
if strings.TrimSpace(content) == "" {
return ""
}
return fmt.Sprintf(`# Active Skills
The following skills are active for this request. Follow them when relevant.
%s`, content)
}
func (cb *ContextBuilder) ResolveActiveSkillsForContext(skillNames []string) []string {
if cb.skillsLoader == nil || len(skillNames) == 0 {
return nil
}
var ordered []string
seen := make(map[string]struct{}, len(skillNames))
for _, name := range skillNames {
@ -1042,19 +1085,9 @@ func (cb *ContextBuilder) buildActiveSkillsContext(skillNames []string) string {
ordered = append(ordered, canonical)
}
if len(ordered) == 0 {
return ""
return nil
}
content := cb.skillsLoader.LoadSkillsForContext(ordered)
if strings.TrimSpace(content) == "" {
return ""
}
return fmt.Sprintf(`# Active Skills
The following skills are active for this request. Follow them when relevant.
%s`, content)
return ordered
}
func (cb *ContextBuilder) buildActiveSkillsPromptParts(skillNames []string) []PromptPart {

View file

@ -35,7 +35,7 @@ type AgentFrontmatter struct {
MaxTurns *int `json:"maxTurns,omitempty"`
Skills []string `json:"skills,omitempty"`
MCPServers []string `json:"mcpServers,omitempty"`
Fields map[string]any `json:"fields,omitempty"`
Fields map[string]any `json:"-"`
}
// AgentPromptDefinition represents the parsed AGENT.md or AGENTS.md prompt file.
@ -45,6 +45,7 @@ type AgentPromptDefinition struct {
Body string `json:"body"`
RawFrontmatter string `json:"raw_frontmatter,omitempty"`
Frontmatter AgentFrontmatter `json:"frontmatter"`
FrontmatterErr string `json:"frontmatter_error,omitempty"`
}
// SoulDefinition represents the resolved SOUL.md file linked to the agent.
@ -146,19 +147,21 @@ func loadUserDefinition(workspace string) *UserDefinition {
func parseAgentPromptDefinition(path, content string) AgentPromptDefinition {
frontmatter, body := splitAgentFrontmatter(content)
parsedFrontmatter, err := parseAgentFrontmatter(path, frontmatter)
return AgentPromptDefinition{
Path: path,
Raw: content,
Body: body,
RawFrontmatter: frontmatter,
Frontmatter: parseAgentFrontmatter(path, frontmatter),
Frontmatter: parsedFrontmatter,
FrontmatterErr: errorString(err),
}
}
func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter {
func parseAgentFrontmatter(path, frontmatter string) (AgentFrontmatter, error) {
frontmatter = strings.TrimSpace(frontmatter)
if frontmatter == "" {
return AgentFrontmatter{}
return AgentFrontmatter{}, nil
}
rawFields := make(map[string]any)
@ -167,7 +170,7 @@ func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter {
"path": path,
"error": err.Error(),
})
return AgentFrontmatter{}
return AgentFrontmatter{}, err
}
var typed struct {
@ -184,7 +187,7 @@ func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter {
"path": path,
"error": err.Error(),
})
return AgentFrontmatter{}
return AgentFrontmatter{}, err
}
return AgentFrontmatter{
@ -196,7 +199,7 @@ func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter {
Skills: append([]string(nil), typed.Skills...),
MCPServers: append([]string(nil), typed.MCPServers...),
Fields: rawFields,
}
}, nil
}
func splitAgentFrontmatter(content string) (frontmatter, body string) {
@ -253,3 +256,10 @@ func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func errorString(err error) string {
if err == nil {
return ""
}
return err.Error()
}

263
pkg/agent/discovery.go Normal file
View file

@ -0,0 +1,263 @@
package agent
import (
"encoding/json"
"path/filepath"
"sort"
"strings"
"github.com/sipeed/picoclaw/pkg/routing"
)
// AgentDescriptor is the structured discovery payload injected into each
// agent's system prompt so the LLM can choose a peer by identity.
type AgentDescriptor struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
// ListAgents returns structured descriptors for every agent in the current
// PicoClaw instance. The current workspace, when provided, is used only to
// order the matching agent first for prompt readability.
func (r *AgentRegistry) ListAgents(workspace string) []AgentDescriptor {
r.mu.RLock()
defer r.mu.RUnlock()
ids := make([]string, 0, len(r.agents))
for id := range r.agents {
ids = append(ids, id)
}
sort.Strings(ids)
selfWorkspace := cleanWorkspacePath(workspace)
descriptors := make([]AgentDescriptor, 0, len(ids))
for _, id := range ids {
agent := r.agents[id]
if agent == nil {
continue
}
descriptors = append(descriptors, r.buildAgentDescriptorLocked(agent))
}
if selfWorkspace == "" {
return descriptors
}
sort.SliceStable(descriptors, func(i, j int) bool {
leftSelf := cleanWorkspacePath(
r.workspaceForAgentIDLocked(descriptors[i].ID),
) == selfWorkspace
rightSelf := cleanWorkspacePath(
r.workspaceForAgentIDLocked(descriptors[j].ID),
) == selfWorkspace
if leftSelf != rightSelf {
return leftSelf
}
return descriptors[i].ID < descriptors[j].ID
})
return descriptors
}
// ListSpawnableAgents returns descriptors only when the current agent can call
// spawn, and only for peers it is allowed to spawn. Restricted peers are
// intentionally omitted from discovery.
func (r *AgentRegistry) ListSpawnableAgents(agentID string) []AgentDescriptor {
r.mu.RLock()
defer r.mu.RUnlock()
parentID := routing.NormalizeAgentID(agentID)
parent, ok := r.agents[parentID]
if !ok || parent == nil {
return nil
}
if !agentHasSpawnTool(parent) {
return nil
}
ids := make([]string, 0, len(r.agents))
for id := range r.agents {
if id == parentID {
continue
}
if !agentAllowsSubagent(parent, id) {
continue
}
ids = append(ids, id)
}
sort.Strings(ids)
descriptors := make([]AgentDescriptor, 0, len(ids))
for _, id := range ids {
agent := r.agents[id]
if agent == nil {
continue
}
descriptors = append(descriptors, r.buildAgentDescriptorLocked(agent))
}
return descriptors
}
// GetAgentDescriptor returns the structured discovery payload for one agent.
func (r *AgentRegistry) GetAgentDescriptor(agentID string) (*AgentDescriptor, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
id := routing.NormalizeAgentID(agentID)
agent, ok := r.agents[id]
if !ok || agent == nil {
return nil, false
}
descriptor := r.buildAgentDescriptorLocked(agent)
return &descriptor, true
}
func (r *AgentRegistry) buildAgentDescriptorLocked(agent *AgentInstance) AgentDescriptor {
definition := loadAgentDefinition(agent.Workspace)
name, description := descriptorIdentity(agent.ID, definition)
return AgentDescriptor{
ID: agent.ID,
Name: name,
Description: description,
}
}
func descriptorIdentity(agentID string, definition AgentContextDefinition) (string, string) {
name := agentID
description := ""
if definition.Agent != nil {
if trimmed := strings.TrimSpace(definition.Agent.Frontmatter.Name); trimmed != "" {
name = trimmed
}
if trimmed := strings.TrimSpace(definition.Agent.Frontmatter.Description); trimmed != "" {
description = trimmed
}
}
if description == "" &&
definition.Agent != nil {
if definition.Source == AgentDefinitionSourceAgent {
description = firstNonEmptyLine(definition.Agent.Body)
} else if definition.Source == AgentDefinitionSourceAgents {
description = firstMeaningfulParagraph(definition.Agent.Body)
}
}
return name, description
}
func firstNonEmptyLine(content string) string {
content = strings.ReplaceAll(content, "\r\n", "\n")
for _, line := range strings.Split(content, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
return trimmed
}
}
return ""
}
func firstMeaningfulParagraph(content string) string {
content = strings.ReplaceAll(content, "\r\n", "\n")
paragraphs := strings.Split(content, "\n\n")
for _, paragraph := range paragraphs {
lines := strings.Split(paragraph, "\n")
parts := make([]string, 0, len(lines))
inFence := false
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") {
inFence = !inFence
continue
}
if inFence || trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "#") {
continue
}
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
trimmed = strings.TrimSpace(trimmed[2:])
}
parts = append(parts, trimmed)
}
if len(parts) == 0 {
continue
}
return strings.Join(parts, " ")
}
return ""
}
func (r *AgentRegistry) workspaceForAgentIDLocked(agentID string) string {
agent, ok := r.agents[routing.NormalizeAgentID(agentID)]
if !ok || agent == nil {
return ""
}
return agent.Workspace
}
func (r *AgentRegistry) defaultAgentIDLocked() string {
if _, ok := r.agents[routing.DefaultAgentID]; ok {
return routing.DefaultAgentID
}
if r.cfg != nil && len(r.cfg.Agents.List) > 0 {
for _, agentCfg := range r.cfg.Agents.List {
if !agentCfg.Default {
continue
}
id := routing.NormalizeAgentID(agentCfg.ID)
if _, ok := r.agents[id]; ok {
return id
}
}
id := routing.NormalizeAgentID(r.cfg.Agents.List[0].ID)
if _, ok := r.agents[id]; ok {
return id
}
}
for id := range r.agents {
return id
}
return ""
}
func cleanWorkspacePath(path string) string {
path = strings.TrimSpace(path)
if path == "" {
return ""
}
return filepath.Clean(path)
}
func formatAgentDiscoverySection(agents []AgentDescriptor) string {
if len(agents) == 0 {
return ""
}
payload := struct {
Agents []AgentDescriptor `json:"agents"`
}{
Agents: agents,
}
encoded, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return ""
}
var header strings.Builder
header.WriteString("# Agent Discovery\n\n")
header.WriteString("This registry lists the peer agents this agent is permitted to spawn.\n")
header.WriteString(
"Choose a peer based on its description. Use only agent IDs listed here when calling spawn.\n\n",
)
header.WriteString("```json\n")
header.Write(encoded)
header.WriteString("\n```")
return header.String()
}

420
pkg/agent/discovery_test.go Normal file
View file

@ -0,0 +1,420 @@
package agent
import (
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestAgentRegistry_ListAgentsBuildsStructuredDescriptors(t *testing.T) {
mainWorkspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
name: Main Frontmatter Name
description: Structured main agent
---
# Agent
Handle general requests.
`,
})
defer cleanupWorkspace(t, mainWorkspace)
supportWorkspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
name: Support Frontmatter Name
description: Support frontmatter description
---
# Agent
Handle support tickets carefully.
`,
})
defer cleanupWorkspace(t, supportWorkspace)
cfg := testCfg([]config.AgentConfig{
{ID: "main", Default: true, Name: "Configured Main", Workspace: mainWorkspace},
{ID: "support", Workspace: supportWorkspace},
})
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
descriptors := registry.ListAgents(mainWorkspace)
if len(descriptors) != 2 {
t.Fatalf("expected 2 descriptors, got %d", len(descriptors))
}
if descriptors[0].ID != "main" {
t.Fatalf("expected current workspace agent first, got %q", descriptors[0].ID)
}
if descriptors[0].Name != "Main Frontmatter Name" {
t.Fatalf("expected frontmatter name to drive discovery, got %q", descriptors[0].Name)
}
if descriptors[0].Description != "Structured main agent" {
t.Fatalf("expected frontmatter description, got %q", descriptors[0].Description)
}
support, ok := registry.GetAgentDescriptor("support")
if !ok || support == nil {
t.Fatal("expected support descriptor lookup to succeed")
}
if support.Name != "Support Frontmatter Name" {
t.Fatalf("expected support frontmatter name, got %q", support.Name)
}
if support.Description != "Support frontmatter description" {
t.Fatalf("expected support frontmatter description, got %q", support.Description)
}
}
func TestAgentRegistry_ListSpawnableAgentsRespectsPermissions(t *testing.T) {
cfg := testCfg([]config.AgentConfig{
{
ID: "parent",
Default: true,
Subagents: &config.SubagentsConfig{
AllowAgents: []string{"child2", "child1"},
},
},
{ID: "child1"},
{ID: "child2"},
{ID: "restricted"},
})
cfg.Tools.Spawn.Enabled = true
cfg.Tools.Subagent.Enabled = true
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{})
defer al.Close()
descriptors := al.GetRegistry().ListSpawnableAgents("parent")
if len(descriptors) != 2 {
t.Fatalf("expected 2 spawnable descriptors, got %d: %+v", len(descriptors), descriptors)
}
if descriptors[0].ID != "child1" || descriptors[1].ID != "child2" {
t.Fatalf("expected sorted spawnable peers only, got %+v", descriptors)
}
}
func TestAgentRegistry_ListSpawnableAgentsRequiresSpawnTool(t *testing.T) {
cfg := testCfg([]config.AgentConfig{
{
ID: "parent",
Default: true,
Subagents: &config.SubagentsConfig{
AllowAgents: []string{"child"},
},
},
{ID: "child"},
})
cfg.Tools.Subagent.Enabled = true
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{})
defer al.Close()
if descriptors := al.GetRegistry().ListSpawnableAgents("parent"); len(descriptors) != 0 {
t.Fatalf("expected no spawnable descriptors without spawn tool, got %+v", descriptors)
}
}
func TestContextBuilder_BuildMessagesIncludesAgentDiscoverySection(t *testing.T) {
mainWorkspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
description: Main agent
---
# Agent
Generalist.
`,
})
defer cleanupWorkspace(t, mainWorkspace)
researchWorkspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
name: Research Agent
description: Research specialist
---
# Agent
Investigate deeply.
`,
})
defer cleanupWorkspace(t, researchWorkspace)
restrictedWorkspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
name: Restricted Agent
description: Restricted specialist
---
# Agent
Handle restricted work.
`,
})
defer cleanupWorkspace(t, restrictedWorkspace)
cfg := testCfg([]config.AgentConfig{
{
ID: "main",
Default: true,
Workspace: mainWorkspace,
Subagents: &config.SubagentsConfig{
AllowAgents: []string{"research"},
},
},
{ID: "research", Workspace: researchWorkspace},
{ID: "restricted", Workspace: restrictedWorkspace},
})
cfg.Tools.ReadFile.Enabled = true
cfg.Tools.WriteFile.Enabled = true
cfg.Tools.Spawn.Enabled = true
cfg.Tools.Subagent.Enabled = true
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{})
defer al.Close()
mainAgent, ok := al.GetRegistry().GetAgent("main")
if !ok || mainAgent == nil {
t.Fatal("expected main agent")
}
messages := mainAgent.ContextBuilder.BuildMessages(
nil,
"",
"delegate wisely",
nil,
"telegram",
"chat-1",
"",
"",
)
if len(messages) == 0 {
t.Fatal("expected messages")
}
systemPrompt := messages[0].Content
if !strings.Contains(systemPrompt, "# Agent Discovery") {
t.Fatalf("expected discovery section in system prompt, got %q", systemPrompt)
}
if strings.Contains(systemPrompt, `"id": "main"`) {
t.Fatalf("did not expect self descriptor in discovery section, got %q", systemPrompt)
}
if !strings.Contains(systemPrompt, `"id": "research"`) ||
!strings.Contains(systemPrompt, `"description": "Research specialist"`) {
t.Fatalf("expected allowed peer descriptor in discovery section, got %q", systemPrompt)
}
if strings.Contains(systemPrompt, `"id": "restricted"`) ||
strings.Contains(systemPrompt, `"description": "Restricted specialist"`) {
t.Fatalf("did not expect restricted peer descriptor in discovery section, got %q", systemPrompt)
}
for _, forbidden := range []string{`"current_agent_id"`, `"available_tools"`, `"model"`, `"channels"`, `"skills"`, `"mcpServers"`, `"tools"`} {
if strings.Contains(systemPrompt, forbidden) {
t.Fatalf("did not expect %s in discovery section, got %q", forbidden, systemPrompt)
}
}
}
func TestContextBuilder_BuildMessagesOmitsAgentDiscoveryWithoutSpawnPermissions(t *testing.T) {
mainWorkspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
description: Main agent
---
# Agent
Generalist.
`,
})
defer cleanupWorkspace(t, mainWorkspace)
researchWorkspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
description: Research specialist
---
# Agent
Investigate deeply.
`,
})
defer cleanupWorkspace(t, researchWorkspace)
cfg := testCfg([]config.AgentConfig{
{ID: "main", Default: true, Workspace: mainWorkspace},
{ID: "research", Workspace: researchWorkspace},
})
cfg.Tools.ReadFile.Enabled = true
cfg.Tools.Spawn.Enabled = true
cfg.Tools.Subagent.Enabled = true
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{})
defer al.Close()
mainAgent, ok := al.GetRegistry().GetAgent("main")
if !ok || mainAgent == nil {
t.Fatal("expected main agent")
}
messages := mainAgent.ContextBuilder.BuildMessages(
nil,
"",
"handle locally",
nil,
"telegram",
"chat-1",
"",
"",
)
if len(messages) == 0 {
t.Fatal("expected messages")
}
systemPrompt := messages[0].Content
if strings.Contains(systemPrompt, "# Agent Discovery") {
t.Fatalf("did not expect discovery section without spawn permissions, got %q", systemPrompt)
}
if strings.Contains(systemPrompt, `"id": "research"`) {
t.Fatalf("did not expect unauthorized peer identity in system prompt, got %q", systemPrompt)
}
}
func TestContextBuilder_BuildMessagesOmitsAgentDiscoveryWithoutSpawnTool(t *testing.T) {
mainWorkspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
description: Main agent
tools: [read_file]
---
# Agent
Generalist.
`,
})
defer cleanupWorkspace(t, mainWorkspace)
researchWorkspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
description: Research specialist
---
# Agent
Investigate deeply.
`,
})
defer cleanupWorkspace(t, researchWorkspace)
cfg := testCfg([]config.AgentConfig{
{
ID: "main",
Default: true,
Workspace: mainWorkspace,
Subagents: &config.SubagentsConfig{
AllowAgents: []string{"research"},
},
},
{ID: "research", Workspace: researchWorkspace},
})
cfg.Tools.ReadFile.Enabled = true
cfg.Tools.Spawn.Enabled = true
cfg.Tools.Subagent.Enabled = true
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{})
defer al.Close()
mainAgent, ok := al.GetRegistry().GetAgent("main")
if !ok || mainAgent == nil {
t.Fatal("expected main agent")
}
messages := mainAgent.ContextBuilder.BuildMessages(
nil,
"",
"handle locally",
nil,
"telegram",
"chat-1",
"",
"",
)
if len(messages) == 0 {
t.Fatal("expected messages")
}
systemPrompt := messages[0].Content
if strings.Contains(systemPrompt, "# Agent Discovery") {
t.Fatalf("did not expect discovery section without spawn tool, got %q", systemPrompt)
}
if strings.Contains(systemPrompt, `"id": "research"`) {
t.Fatalf("did not expect peer identity without spawn tool, got %q", systemPrompt)
}
}
func TestContextBuilder_BuildMessagesOmitsAgentDiscoverySectionForSingleton(t *testing.T) {
mainWorkspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
description: Main agent
---
# Agent
Generalist.
`,
})
defer cleanupWorkspace(t, mainWorkspace)
cfg := testCfg([]config.AgentConfig{
{ID: "main", Default: true, Workspace: mainWorkspace},
})
cfg.Tools.ReadFile.Enabled = true
cfg.Tools.Spawn.Enabled = true
cfg.Tools.Subagent.Enabled = true
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{})
defer al.Close()
mainAgent, ok := al.GetRegistry().GetAgent("main")
if !ok || mainAgent == nil {
t.Fatal("expected main agent")
}
messages := mainAgent.ContextBuilder.BuildMessages(
nil,
"",
"handle locally",
nil,
"telegram",
"chat-1",
"",
"",
)
if len(messages) == 0 {
t.Fatal("expected messages")
}
systemPrompt := messages[0].Content
if strings.Contains(systemPrompt, "# Agent Discovery") {
t.Fatalf("did not expect discovery section for singleton registry, got %q", systemPrompt)
}
}
func TestAgentRegistry_ListAgentsFallsBackToFirstNonEmptyAgentLine(t *testing.T) {
workspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
name: Research Agent
---
First useful line.
Second line.
`,
})
defer cleanupWorkspace(t, workspace)
cfg := testCfg([]config.AgentConfig{
{ID: "research", Default: true, Workspace: workspace},
})
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
descriptor, ok := registry.GetAgentDescriptor("research")
if !ok || descriptor == nil {
t.Fatal("expected research descriptor lookup to succeed")
}
if descriptor.Description != "First useful line." {
t.Fatalf("descriptor.Description = %q, want %q", descriptor.Description, "First useful line.")
}
}

View file

@ -20,12 +20,39 @@ type TurnStartPayload struct {
MediaCount int
}
const (
skillContextTriggerInitialBuild = "initial_build"
skillContextTriggerContextRetryRebuild = "context_retry_rebuild"
)
type SkillContextSnapshot struct {
Sequence int `json:"sequence"`
Trigger string `json:"trigger"`
SkillNames []string `json:"skill_names,omitempty"`
}
type ToolExecutionRecord struct {
Name string `json:"name"`
Success bool `json:"success"`
ErrorSummary string `json:"error_summary,omitempty"`
SkillNames []string `json:"skill_names,omitempty"`
}
// TurnEndPayload describes the completion of a turn.
type TurnEndPayload struct {
Status TurnEndStatus
Iterations int
Duration time.Duration
FinalContentLen int
Status TurnEndStatus
Workspace string
Iterations int
Duration time.Duration
FinalContentLen int
UserMessage string
FinalContent string
ActiveSkills []string
AttemptedSkills []string
FinalSuccessfulPath []string
SkillContextSnapshots []SkillContextSnapshot
ToolKinds []string
ToolExecutions []ToolExecutionRecord
}
// LLMRequestPayload describes an outbound LLM request.

View file

@ -1,5 +1,11 @@
package agent
import (
"time"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
)
// HookMeta contains correlation fields shared by agent hook requests and
// runtime events emitted from turn processing.
type HookMeta struct {
@ -12,3 +18,42 @@ type HookMeta struct {
Source string
turnContext *TurnContext
}
// EventKind is the legacy in-agent event kind alias kept for tests and
// compatibility shims on top of the runtime event bus.
type EventKind = runtimeevents.Kind
const (
EventKindTurnStart EventKind = runtimeevents.KindAgentTurnStart
EventKindTurnEnd EventKind = runtimeevents.KindAgentTurnEnd
EventKindLLMRequest EventKind = runtimeevents.KindAgentLLMRequest
EventKindLLMDelta EventKind = runtimeevents.KindAgentLLMDelta
EventKindLLMResponse EventKind = runtimeevents.KindAgentLLMResponse
EventKindLLMRetry EventKind = runtimeevents.KindAgentLLMRetry
EventKindContextCompress EventKind = runtimeevents.KindAgentContextCompress
EventKindSessionSummarize EventKind = runtimeevents.KindAgentSessionSummarize
EventKindToolExecStart EventKind = runtimeevents.KindAgentToolExecStart
EventKindToolExecEnd EventKind = runtimeevents.KindAgentToolExecEnd
EventKindToolExecSkipped EventKind = runtimeevents.KindAgentToolExecSkipped
EventKindSteeringInjected EventKind = runtimeevents.KindAgentSteeringInjected
EventKindFollowUpQueued EventKind = runtimeevents.KindAgentFollowUpQueued
EventKindInterruptReceived EventKind = runtimeevents.KindAgentInterruptReceived
EventKindSubTurnSpawn EventKind = runtimeevents.KindAgentSubTurnSpawn
EventKindSubTurnEnd EventKind = runtimeevents.KindAgentSubTurnEnd
EventKindSubTurnResultDelivered EventKind = runtimeevents.KindAgentSubTurnResultDelivered
EventKindSubTurnOrphan EventKind = runtimeevents.KindAgentSubTurnOrphan
EventKindError EventKind = runtimeevents.KindAgentError
)
// EventMeta is the legacy name for hook metadata.
type EventMeta = HookMeta
// Event is the legacy agent event envelope exposed by SubscribeEvents and a
// handful of tests. Runtime code publishes pkg/events.Event internally.
type Event struct {
Kind EventKind
Time time.Time
Meta EventMeta
Context *TurnContext
Payload any
}

View file

@ -0,0 +1,444 @@
package agent
import (
"context"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/config"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/evolution"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
type evolutionBridge struct {
cfg config.EvolutionConfig
registry *AgentRegistry
runtime *evolution.Runtime
coldPathRunner *evolution.ColdPathRunner
runtimeSub runtimeevents.Subscription
bgCtx context.Context
cancel context.CancelFunc
closeMu sync.Mutex
closed bool
wg sync.WaitGroup
isCurrent func(*evolutionBridge) bool
scheduledMu sync.Mutex
scheduledWorkspaces map[string]struct{}
}
const evolutionDirectDeliveryAttr = "evolution_direct_delivery"
func newEvolutionBridge(
registry *AgentRegistry,
cfg *config.Config,
provider providers.LLMProvider,
) (*evolutionBridge, error) {
if cfg == nil {
return nil, nil
}
modelID := resolvedEvolutionModelID(cfg, provider)
runtime, err := evolution.NewRuntime(evolution.RuntimeOptions{
Config: cfg.Evolution,
PatternClusterer: evolution.NewLLMPatternClusterer(
provider,
modelID,
evolution.NewHeuristicPatternClusterer(cfg.Evolution.EffectiveMinTaskCount(), nil),
cfg.Evolution.EffectiveMinTaskCount(),
nil,
),
GeneratorFactory: func(workspace string) evolution.DraftGenerator {
return evolution.NewDraftGeneratorForWorkspace(workspace, provider, modelID)
},
SuccessJudgeFactory: func(workspace string) evolution.SuccessJudge {
return evolution.NewLLMTaskSuccessJudge(provider, modelID, &evolution.HeuristicSuccessJudge{})
},
ApplierFactory: func(workspace string) *evolution.Applier {
return evolution.NewApplier(evolution.NewPaths(workspace, cfg.Evolution.StateDir), nil)
},
})
if err != nil {
return nil, err
}
bgCtx, cancel := context.WithCancel(context.Background())
bridge := &evolutionBridge{
cfg: cfg.Evolution,
registry: registry,
runtime: runtime,
bgCtx: bgCtx,
cancel: cancel,
}
if cfg.Evolution.RunsColdPathAutomatically() {
bridge.coldPathRunner = evolution.NewColdPathRunnerWithErrorHandler(runtime, func(err error) {
logger.WarnCF("agent", "Cold path run failed", map[string]any{
"error": err.Error(),
})
})
}
if cfg.Evolution.RunsColdPathScheduled() {
bridge.startScheduledColdPath(cfg.Agents.Defaults.Workspace, cfg.Evolution.EffectiveColdPathTimes())
bridge.rememberScheduledColdPathWorkspaces(registryWorkspaces(registry))
}
return bridge, nil
}
func resolvedEvolutionModelID(cfg *config.Config, provider providers.LLMProvider) string {
if cfg != nil {
if modelID := cfg.Agents.Defaults.GetModelName(); modelID != "" {
return modelID
}
}
if provider != nil {
return provider.GetDefaultModel()
}
return ""
}
func (b *evolutionBridge) Close() error {
if b == nil {
return nil
}
if b.runtimeSub != nil {
if err := b.runtimeSub.Close(); err != nil {
logger.WarnCF("agent", "Failed to close evolution runtime subscription", map[string]any{
"error": err.Error(),
})
}
<-b.runtimeSub.Done()
}
b.closeMu.Lock()
alreadyClosed := b.closed
b.closed = true
b.closeMu.Unlock()
if alreadyClosed {
return nil
}
if b.cancel != nil {
b.cancel()
}
var closeErr error
if b.coldPathRunner != nil {
closeErr = b.coldPathRunner.Close()
}
b.wg.Wait()
return closeErr
}
func (b *evolutionBridge) OnEvent(_ context.Context, evt Event) error {
if b == nil || !b.cfg.Enabled || b.runtime == nil {
return nil
}
switch evt.Kind {
case EventKindTurnEnd:
payload, ok := evt.Payload.(TurnEndPayload)
if !ok {
return nil
}
b.handleTurnEndAsync(evt.Meta, payload)
return nil
}
return nil
}
func (b *evolutionBridge) OnRuntimeEvent(_ context.Context, evt runtimeevents.Event) error {
if b == nil || !b.cfg.Enabled || b.runtime == nil || evt.Kind != runtimeevents.KindAgentTurnEnd {
return nil
}
if b.isCurrent != nil && !b.isCurrent(b) {
return nil
}
if deliveredDirectly, _ := evt.Attrs[evolutionDirectDeliveryAttr].(bool); deliveredDirectly {
return nil
}
payload, ok := evt.Payload.(TurnEndPayload)
if !ok {
return nil
}
b.handleTurnEndAsync(hookMetaFromRuntimeEvent(evt), payload)
return nil
}
func (b *evolutionBridge) handleRuntimeTurnEnd(evt runtimeevents.Event) bool {
if b == nil || !b.cfg.Enabled || b.runtime == nil || evt.Kind != runtimeevents.KindAgentTurnEnd {
return false
}
payload, ok := evt.Payload.(TurnEndPayload)
if !ok {
return false
}
return b.handleTurnEndAsync(hookMetaFromRuntimeEvent(evt), payload)
}
func (b *evolutionBridge) handleTurnEndAsync(meta EventMeta, payload TurnEndPayload) bool {
if b == nil || b.runtime == nil {
return false
}
input := evolution.TurnCaseInput{
Workspace: payload.Workspace,
WorkspaceID: payload.Workspace,
TurnID: meta.TurnID,
SessionKey: meta.SessionKey,
AgentID: meta.AgentID,
Status: string(payload.Status),
UserMessage: payload.UserMessage,
FinalContent: payload.FinalContent,
ToolKinds: append([]string(nil), payload.ToolKinds...),
ToolExecutions: toEvolutionToolExecutions(payload.ToolExecutions),
ActiveSkillNames: append([]string(nil), payload.ActiveSkills...),
AttemptedSkillNames: append([]string(nil), payload.AttemptedSkills...),
FinalSuccessfulPath: append([]string(nil), payload.FinalSuccessfulPath...),
SkillContextSnapshots: toEvolutionSkillContextSnapshots(payload.SkillContextSnapshots),
}
b.rememberScheduledColdPathWorkspace(input.Workspace)
b.closeMu.Lock()
if b.closed {
b.closeMu.Unlock()
return false
}
b.wg.Add(1)
b.closeMu.Unlock()
go func() {
defer b.wg.Done()
if err := b.runtime.FinalizeTurn(b.bgCtx, input); err != nil {
logger.WarnCF("agent", "Evolution finalize turn failed", map[string]any{
"error": err.Error(),
"turn_id": input.TurnID,
"workspace": input.Workspace,
})
return
}
if b.coldPathRunner != nil && b.cfg.RunsColdPathAfterTurn() {
b.coldPathRunner.Trigger(input.Workspace)
}
}()
return true
}
func (b *evolutionBridge) subscribeRuntimeEvents(ch runtimeevents.EventChannel) error {
if b == nil || ch == nil {
return nil
}
sub, err := ch.Source("agent").OfKind(runtimeevents.KindAgentTurnEnd).Subscribe(
b.bgCtx,
runtimeevents.SubscribeOptions{
Name: "evolution-bridge",
Buffer: hookObserverBufferSize,
Backpressure: runtimeevents.Block,
Concurrency: runtimeevents.Locked,
},
func(ctx context.Context, evt runtimeevents.Event) error {
return b.OnRuntimeEvent(ctx, evt)
},
)
if err != nil {
return err
}
b.runtimeSub = sub
return nil
}
func (b *evolutionBridge) setCurrentCheck(check func(*evolutionBridge) bool) {
if b == nil {
return
}
b.closeMu.Lock()
defer b.closeMu.Unlock()
b.isCurrent = check
}
func (b *evolutionBridge) startScheduledColdPath(workspace string, times []string) {
if b == nil || b.coldPathRunner == nil || len(times) == 0 {
return
}
b.rememberScheduledColdPathWorkspace(workspace)
schedule := parseColdPathSchedule(times)
if len(schedule) == 0 {
logger.WarnCF("agent", "No valid evolution cold path schedule times configured", map[string]any{
"times": times,
})
return
}
b.wg.Add(1)
go func() {
defer b.wg.Done()
for {
now := time.Now()
next := nextColdPathScheduledTime(now, schedule)
timer := time.NewTimer(time.Until(next))
select {
case <-timer.C:
for _, workspace := range b.scheduledColdPathWorkspaces() {
b.coldPathRunner.Trigger(workspace)
}
case <-b.bgCtx.Done():
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
return
}
}
}()
}
func (b *evolutionBridge) rememberScheduledColdPathWorkspace(workspace string) {
if b == nil || !b.cfg.RunsColdPathScheduled() {
return
}
workspace = strings.TrimSpace(workspace)
if workspace == "" {
return
}
b.scheduledMu.Lock()
defer b.scheduledMu.Unlock()
if b.scheduledWorkspaces == nil {
b.scheduledWorkspaces = make(map[string]struct{})
}
b.scheduledWorkspaces[workspace] = struct{}{}
}
func (b *evolutionBridge) rememberScheduledColdPathWorkspaces(workspaces []string) {
for _, workspace := range workspaces {
b.rememberScheduledColdPathWorkspace(workspace)
}
}
func (b *evolutionBridge) scheduledColdPathWorkspaces() []string {
if b == nil {
return nil
}
b.scheduledMu.Lock()
defer b.scheduledMu.Unlock()
out := make([]string, 0, len(b.scheduledWorkspaces))
for workspace := range b.scheduledWorkspaces {
out = append(out, workspace)
}
sort.Strings(out)
return out
}
func registryWorkspaces(registry *AgentRegistry) []string {
if registry == nil {
return nil
}
registry.mu.RLock()
defer registry.mu.RUnlock()
out := make([]string, 0, len(registry.agents))
seen := make(map[string]struct{}, len(registry.agents))
for _, agent := range registry.agents {
if agent == nil {
continue
}
workspace := strings.TrimSpace(agent.Workspace)
if workspace == "" {
continue
}
if _, ok := seen[workspace]; ok {
continue
}
seen[workspace] = struct{}{}
out = append(out, workspace)
}
sort.Strings(out)
return out
}
type coldPathScheduleTime struct {
hour int
minute int
}
func parseColdPathSchedule(values []string) []coldPathScheduleTime {
out := make([]coldPathScheduleTime, 0, len(values))
seen := make(map[coldPathScheduleTime]struct{}, len(values))
for _, value := range values {
parts := strings.Split(strings.TrimSpace(value), ":")
if len(parts) != 2 {
continue
}
hour, err := strconv.Atoi(parts[0])
if err != nil || hour < 0 || hour > 23 {
continue
}
minute, err := strconv.Atoi(parts[1])
if err != nil || minute < 0 || minute > 59 {
continue
}
item := coldPathScheduleTime{hour: hour, minute: minute}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
out = append(out, item)
}
sort.Slice(out, func(i, j int) bool {
if out[i].hour != out[j].hour {
return out[i].hour < out[j].hour
}
return out[i].minute < out[j].minute
})
return out
}
func nextColdPathScheduledTime(now time.Time, schedule []coldPathScheduleTime) time.Time {
for _, item := range schedule {
candidate := time.Date(now.Year(), now.Month(), now.Day(), item.hour, item.minute, 0, 0, now.Location())
if candidate.After(now) {
return candidate
}
}
first := schedule[0]
tomorrow := now.AddDate(0, 0, 1)
return time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), first.hour, first.minute, 0, 0, now.Location())
}
func toEvolutionSkillContextSnapshots(input []SkillContextSnapshot) []evolution.SkillContextSnapshot {
if len(input) == 0 {
return nil
}
out := make([]evolution.SkillContextSnapshot, 0, len(input))
for _, snapshot := range input {
out = append(out, evolution.SkillContextSnapshot{
Sequence: snapshot.Sequence,
Trigger: snapshot.Trigger,
SkillNames: append([]string(nil), snapshot.SkillNames...),
})
}
return out
}
func toEvolutionToolExecutions(input []ToolExecutionRecord) []evolution.ToolExecutionRecord {
if len(input) == 0 {
return nil
}
out := make([]evolution.ToolExecutionRecord, 0, len(input))
for _, record := range input {
out = append(out, evolution.ToolExecutionRecord{
Name: record.Name,
Success: record.Success,
ErrorSummary: record.ErrorSummary,
SkillNames: append([]string(nil), record.SkillNames...),
})
}
return out
}

File diff suppressed because it is too large Load diff

View file

@ -38,8 +38,10 @@ type AgentInstance struct {
Sessions session.SessionStore
ContextBuilder *ContextBuilder
Tools *tools.ToolRegistry
Definition AgentContextDefinition
Subagents *config.SubagentsConfig
SkillsFilter []string
MCPServerAllowlist map[string]struct{}
Candidates []providers.FallbackCandidate
// Router is non-nil when model routing is configured and the light model
@ -74,7 +76,9 @@ func NewAgentInstance(
workspace := resolveAgentWorkspace(agentCfg, defaults)
os.MkdirAll(workspace, 0o755)
model := resolveAgentModel(agentCfg, defaults)
definition := loadAgentDefinition(workspace)
model := resolveAgentModel(agentCfg, defaults, definition)
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
restrict := defaults.RestrictToWorkspace
@ -83,8 +87,11 @@ func NewAgentInstance(
// Compile path whitelist patterns from config.
allowReadPaths := buildAllowReadPatterns(cfg)
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
agentToolAllowlist := resolveAgentToolAllowlist(definition)
agentMCPServerAllowlist := resolveAgentMCPServerAllowlist(definition)
toolsRegistry := tools.NewToolRegistry()
toolsRegistry.SetAllowlist(agentToolAllowlist)
if cfg.Tools.IsToolEnabled("read_file") {
maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize
@ -121,7 +128,7 @@ func NewAgentInstance(
sessionsDir := filepath.Join(workspace, "sessions")
sessions := initSessionStore(sessionsDir)
mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled
mcpDiscoveryActive := agentHasDiscoverableMCPServers(cfg, agentMCPServerAllowlist)
contextBuilder := NewContextBuilder(workspace).
WithToolDiscovery(
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
@ -138,9 +145,14 @@ func NewAgentInstance(
if agentCfg != nil {
agentID = routing.NormalizeAgentID(agentCfg.ID)
agentName = agentCfg.Name
if definition.Agent != nil && strings.TrimSpace(definition.Agent.Frontmatter.Name) != "" {
agentName = strings.TrimSpace(definition.Agent.Frontmatter.Name)
}
subagents = agentCfg.Subagents
skillsFilter = agentCfg.Skills
skillsFilter = resolveAgentSkillsFilter(agentCfg, definition)
}
provider = resolvePrimaryProviderForAgent(cfg, workspace, agentID, model, provider)
warnOnUnknownAgentMCPServerDeclarations(agentID, workspace, cfg, definition)
maxIter := defaults.MaxToolIterations
if maxIter == 0 {
@ -200,8 +212,15 @@ func NewAgentInstance(
if len(resolved) > 0 {
lightModelCfg, err := resolvedModelConfig(cfg, rc.LightModel, workspace)
if err != nil {
logger.WarnCF("agent", "Routing light model config invalid; routing disabled",
map[string]any{"light_model": rc.LightModel, "agent_id": agentID, "error": err.Error()})
logger.WarnCF(
"agent",
"Routing light model config invalid; routing disabled",
map[string]any{
"light_model": rc.LightModel,
"agent_id": agentID,
"error": err.Error(),
},
)
} else {
lp, _, err := providers.CreateProviderFromConfig(lightModelCfg)
if err != nil {
@ -240,8 +259,10 @@ func NewAgentInstance(
Sessions: sessions,
ContextBuilder: contextBuilder,
Tools: toolsRegistry,
Definition: definition,
Subagents: subagents,
SkillsFilter: skillsFilter,
MCPServerAllowlist: agentMCPServerAllowlist,
Candidates: candidates,
Router: router,
LightCandidates: lightCandidates,
@ -286,13 +307,55 @@ func populateCandidateProvidersFromNames(
}
}
// resolvePrimaryProviderForAgent resolves a dedicated provider for the active
// primary model when the model points at a model_list entry. This keeps the
// agent's single-candidate path aligned with the selected model's own
// provider/api_base/api_key instead of inheriting the process default provider.
func resolvePrimaryProviderForAgent(
cfg *config.Config,
workspace string,
agentID string,
model string,
fallback providers.LLMProvider,
) providers.LLMProvider {
model = strings.TrimSpace(model)
if cfg == nil || model == "" {
return fallback
}
modelCfg := lookupModelConfigByRef(cfg, model)
if modelCfg == nil {
return fallback
}
clone := *modelCfg
if clone.Workspace == "" {
clone.Workspace = workspace
}
resolvedProvider, _, err := providers.CreateProviderFromConfig(&clone)
if err != nil {
logger.WarnCF("agent", "Primary model provider init failed; using injected provider",
map[string]any{
"agent_id": agentID,
"model": model,
"error": err.Error(),
})
return fallback
}
if resolvedProvider == nil {
return fallback
}
return resolvedProvider
}
// resolveAgentWorkspace determines the workspace directory for an agent.
func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
return expandHome(strings.TrimSpace(agentCfg.Workspace))
}
// Use the configured default workspace (respects PICOCLAW_HOME)
if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" {
if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" ||
routing.NormalizeAgentID(agentCfg.ID) == "main" {
return expandHome(defaults.Workspace)
}
// For named agents without explicit workspace, use default workspace with agent ID suffix
@ -301,7 +364,14 @@ func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentD
}
// resolveAgentModel resolves the primary model for an agent.
func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
func resolveAgentModel(
agentCfg *config.AgentConfig,
defaults *config.AgentDefaults,
definition AgentContextDefinition,
) string {
if definition.Agent != nil && strings.TrimSpace(definition.Agent.Frontmatter.Model) != "" {
return strings.TrimSpace(definition.Agent.Frontmatter.Model)
}
if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
return strings.TrimSpace(agentCfg.Model.Primary)
}
@ -316,6 +386,27 @@ func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentD
return defaults.ModelFallbacks
}
func resolveAgentSkillsFilter(
agentCfg *config.AgentConfig,
definition AgentContextDefinition,
) []string {
if definition.Agent != nil && definition.Agent.Frontmatter.Skills != nil {
return append([]string(nil), definition.Agent.Frontmatter.Skills...)
}
if agentCfg == nil || agentCfg.Skills == nil {
return nil
}
return append([]string(nil), agentCfg.Skills...)
}
func (a *AgentInstance) AllowsMCPServer(serverName string) bool {
if a == nil || a.MCPServerAllowlist == nil {
return true
}
_, ok := a.MCPServerAllowlist[strings.ToLower(strings.TrimSpace(serverName))]
return ok
}
func compilePatterns(patterns []string) []*regexp.Regexp {
compiled := make([]*regexp.Regexp, 0, len(patterns))
for _, p := range patterns {

View file

@ -10,6 +10,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/tools"
)
func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) {
@ -616,3 +617,285 @@ func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) {
t.Fatal("read_file tool should still be registered")
}
}
func TestNewAgentInstance_UsesFrontmatterModelAndSkills(t *testing.T) {
workspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
model: frontmatter-model
skills: [frontmatter-skill]
mcpServers: [GitHub, filesystem]
---
# Agent
Use frontmatter identity.
`,
})
defer cleanupWorkspace(t, workspace)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: workspace,
ModelName: "default-model",
},
},
}
agent := NewAgentInstance(&config.AgentConfig{
ID: "research",
Workspace: workspace,
Model: &config.AgentModelConfig{
Primary: "config-model",
},
Skills: []string{"config-skill"},
}, &cfg.Agents.Defaults, cfg, &mockProvider{})
if agent.Model != "frontmatter-model" {
t.Fatalf("agent.Model = %q, want frontmatter-model", agent.Model)
}
if len(agent.SkillsFilter) != 1 || agent.SkillsFilter[0] != "frontmatter-skill" {
t.Fatalf("agent.SkillsFilter = %v, want [frontmatter-skill]", agent.SkillsFilter)
}
if !agent.AllowsMCPServer("github") {
t.Fatal("expected github MCP server to be allowed from frontmatter")
}
if !agent.AllowsMCPServer("FILESYSTEM") {
t.Fatal("expected filesystem MCP server matching to be case-insensitive")
}
if agent.AllowsMCPServer("slack") {
t.Fatal("expected slack MCP server to be blocked by frontmatter allowlist")
}
}
func TestNewAgentInstance_UsesResolvedProviderForFrontmatterPrimaryModel(t *testing.T) {
workspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
model: claude-frontmatter
---
# Agent
`,
})
defer cleanupWorkspace(t, workspace)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: workspace,
Provider: "openai",
ModelName: "default-model",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "claude-frontmatter",
Model: "anthropic/claude-3-7-sonnet",
APIKeys: config.SimpleSecureStrings("test-anthropic-key"),
Workspace: workspace,
},
},
}
defaultProvider := &mockProvider{}
agent := NewAgentInstance(&config.AgentConfig{
ID: "research",
Workspace: workspace,
}, &cfg.Agents.Defaults, cfg, defaultProvider)
if agent.Model != "claude-frontmatter" {
t.Fatalf("agent.Model = %q, want %q", agent.Model, "claude-frontmatter")
}
if len(agent.Candidates) != 1 {
t.Fatalf("len(agent.Candidates) = %d, want 1", len(agent.Candidates))
}
if got := agent.Candidates[0].Provider; got != "anthropic" {
t.Fatalf("primary candidate provider = %q, want %q", got, "anthropic")
}
if got := agent.Candidates[0].Model; got != "claude-3-7-sonnet" {
t.Fatalf("primary candidate model = %q, want %q", got, "claude-3-7-sonnet")
}
if agent.Provider == defaultProvider {
t.Fatal("expected primary provider to be resolved from model_list instead of using injected default provider")
}
}
func TestNewAgentInstance_SuppressesToolDiscoveryPromptWhenNoMCPServersSelected(t *testing.T) {
workspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
mcpServers: []
---
# Agent
`,
})
defer cleanupWorkspace(t, workspace)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: workspace,
ModelName: "default-model",
},
},
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Discovery: config.ToolDiscoveryConfig{
Enabled: true,
UseBM25: true,
UseRegex: false,
},
Servers: map[string]config.MCPServerConfig{
"github": {Enabled: true},
},
},
},
}
agent := NewAgentInstance(&config.AgentConfig{
ID: "research",
Workspace: workspace,
}, &cfg.Agents.Defaults, cfg, &mockProvider{})
if agent.AllowsMCPServer("github") {
t.Fatal("expected empty mcpServers allowlist to deny all servers")
}
messages := agent.ContextBuilder.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"})
if prompt := messages[0].Content; strings.Contains(prompt, tools.BM25SearchToolName) {
t.Fatalf("expected no tool discovery prompt when no MCP servers are selected, got %q", prompt)
}
}
func TestNewAgentInstance_IncludesToolDiscoveryPromptWhenDiscoverableMCPServerSelected(t *testing.T) {
workspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
mcpServers: [github]
---
# Agent
`,
})
defer cleanupWorkspace(t, workspace)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: workspace,
ModelName: "default-model",
},
},
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{Enabled: true},
Discovery: config.ToolDiscoveryConfig{
Enabled: true,
UseBM25: true,
UseRegex: false,
},
Servers: map[string]config.MCPServerConfig{
"github": {Enabled: true},
},
},
},
}
agent := NewAgentInstance(&config.AgentConfig{
ID: "research",
Workspace: workspace,
}, &cfg.Agents.Defaults, cfg, &mockProvider{})
messages := agent.ContextBuilder.BuildMessagesFromPrompt(PromptBuildRequest{CurrentMessage: "hello"})
if prompt := messages[0].Content; !strings.Contains(prompt, tools.BM25SearchToolName) {
t.Fatalf("expected tool discovery prompt when a discoverable MCP server is selected, got %q", prompt)
}
}
func TestNewAgentInstance_InvalidFrontmatterFailsClosedForToolsAndMCPServers(t *testing.T) {
workspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
tools: [read_file
mcpServers: [github]
---
# Agent
`,
})
defer cleanupWorkspace(t, workspace)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: workspace,
ModelName: "default-model",
},
},
Tools: config.ToolsConfig{
ReadFile: config.ReadFileToolConfig{Enabled: true},
},
}
agent := NewAgentInstance(&config.AgentConfig{
ID: "research",
Workspace: workspace,
}, &cfg.Agents.Defaults, cfg, &mockProvider{})
if _, ok := agent.Tools.Get("read_file"); ok {
t.Fatal("expected malformed frontmatter to fail closed and block read_file")
}
if agent.AllowsMCPServer("github") {
t.Fatal("expected malformed frontmatter to fail closed for MCP servers")
}
}
func TestNewAgentInstance_ExplicitEmptyToolsFieldBlocksAllTools(t *testing.T) {
tests := []struct {
name string
toolsSnippet string
}{
{
name: "empty list",
toolsSnippet: "tools: []",
},
{
name: "blank field",
toolsSnippet: "tools:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
workspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
` + tt.toolsSnippet + `
---
# Agent
`,
})
defer cleanupWorkspace(t, workspace)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: workspace,
ModelName: "default-model",
},
},
Tools: config.ToolsConfig{
ReadFile: config.ReadFileToolConfig{Enabled: true},
ListDir: config.ToolConfig{Enabled: true},
},
}
agent := NewAgentInstance(&config.AgentConfig{
ID: "research",
Workspace: workspace,
}, &cfg.Agents.Defaults, cfg, &mockProvider{})
if got := agent.Tools.List(); len(got) != 0 {
t.Fatalf("agent tools = %v, want no registered tools", got)
}
if _, ok := agent.Tools.Get("read_file"); ok {
t.Fatal("expected read_file to be blocked by explicit empty tools field")
}
if _, ok := agent.Tools.Get("list_dir"); ok {
t.Fatal("expected list_dir to be blocked by explicit empty tools field")
}
})
}
}

177
pkg/agent/legacy_events.go Normal file
View file

@ -0,0 +1,177 @@
package agent
import (
"context"
"sync"
"sync/atomic"
"github.com/sipeed/picoclaw/pkg/bus"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
)
const defaultEventSubscriberBuffer = 16
// EventSubscription identifies a legacy subscriber channel returned by
// AgentLoop.SubscribeEvents.
type EventSubscription struct {
ID uint64
C <-chan Event
}
type legacyEventSubscription struct {
cancel context.CancelFunc
sub runtimeevents.Subscription
}
var (
legacyEventSubSeq atomic.Uint64
legacyEventSubLock sync.Map
)
// SubscribeEvents exposes the previous in-agent event subscription API on top
// of the runtime event bus for tests and compatibility.
func (al *AgentLoop) SubscribeEvents(buffer int) EventSubscription {
if buffer <= 0 {
buffer = defaultEventSubscriberBuffer
}
out := make(chan Event, buffer)
if al == nil || al.runtimeEvents == nil {
close(out)
return EventSubscription{C: out}
}
ctx, cancel := context.WithCancel(context.Background())
sub, in, err := al.runtimeEvents.Channel().
Source("agent").
OfKind(legacyAgentEventKinds()...).
SubscribeChan(ctx, runtimeevents.SubscribeOptions{
Name: "legacy-agent-events",
Buffer: buffer,
})
if err != nil {
cancel()
close(out)
return EventSubscription{C: out}
}
id := legacyEventSubSeq.Add(1)
legacyEventSubLock.Store(id, legacyEventSubscription{cancel: cancel, sub: sub})
go func() {
defer legacyEventSubLock.LoadAndDelete(id)
defer close(out)
for {
select {
case <-ctx.Done():
return
case evt, ok := <-in:
if !ok {
return
}
select {
case out <- legacyEventFromRuntimeEvent(evt):
case <-ctx.Done():
return
}
}
}
}()
return EventSubscription{ID: id, C: out}
}
func (al *AgentLoop) UnsubscribeEvents(id uint64) {
if id == 0 {
return
}
value, ok := legacyEventSubLock.LoadAndDelete(id)
if !ok {
return
}
sub := value.(legacyEventSubscription)
sub.cancel()
if sub.sub != nil {
_ = sub.sub.Close()
}
}
func legacyEventFromRuntimeEvent(evt runtimeevents.Event) Event {
meta := hookMetaFromRuntimeEvent(evt)
return Event{
Kind: evt.Kind,
Time: evt.Time,
Meta: meta,
Context: turnContextFromRuntimeScope(evt.Scope),
Payload: evt.Payload,
}
}
func hookMetaFromRuntimeEvent(evt runtimeevents.Event) HookMeta {
meta := HookMeta{
AgentID: evt.Scope.AgentID,
TurnID: evt.Scope.TurnID,
ParentTurnID: evt.Correlation.ParentTurnID,
SessionKey: evt.Scope.SessionKey,
TracePath: evt.Correlation.TraceID,
}
if evt.Attrs != nil {
if source, ok := evt.Attrs["agent_source"].(string); ok {
meta.Source = source
}
if iteration, ok := evt.Attrs["iteration"].(int); ok {
meta.Iteration = iteration
}
}
return meta
}
func turnContextFromRuntimeScope(scope runtimeevents.Scope) *TurnContext {
if scope.Channel == "" &&
scope.Account == "" &&
scope.ChatID == "" &&
scope.ChatType == "" &&
scope.TopicID == "" &&
scope.SpaceID == "" &&
scope.SpaceType == "" &&
scope.SenderID == "" &&
scope.MessageID == "" {
return nil
}
return &TurnContext{
Inbound: &bus.InboundContext{
Channel: scope.Channel,
Account: scope.Account,
ChatID: scope.ChatID,
ChatType: scope.ChatType,
TopicID: scope.TopicID,
SpaceID: scope.SpaceID,
SpaceType: scope.SpaceType,
SenderID: scope.SenderID,
MessageID: scope.MessageID,
},
}
}
func legacyAgentEventKinds() []runtimeevents.Kind {
return []runtimeevents.Kind{
EventKindTurnStart,
EventKindTurnEnd,
EventKindLLMRequest,
EventKindLLMDelta,
EventKindLLMResponse,
EventKindLLMRetry,
EventKindContextCompress,
EventKindSessionSummarize,
EventKindToolExecStart,
EventKindToolExecEnd,
EventKindToolExecSkipped,
EventKindSteeringInjected,
EventKindFollowUpQueued,
EventKindInterruptReceived,
EventKindSubTurnSpawn,
EventKindSubTurnEnd,
EventKindSubTurnResultDelivered,
EventKindSubTurnOrphan,
EventKindError,
}
}

View file

@ -0,0 +1,76 @@
package agent
import (
"context"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
)
func TestSubscribeEventsFiltersRuntimeBusToLegacyAgentEvents(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: t.TempDir(),
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 3,
},
},
}
al := NewAgentLoop(cfg, bus.NewMessageBus(), &simpleMockProvider{response: "ok"})
defer al.Close()
sub := al.SubscribeEvents(4)
defer al.UnsubscribeEvents(sub.ID)
al.RuntimeEventBus().Publish(context.Background(), runtimeevents.Event{
Kind: runtimeevents.KindGatewayReady,
Source: runtimeevents.Source{Component: "gateway"},
})
select {
case evt := <-sub.C:
t.Fatalf("legacy subscriber received non-agent runtime event: %s", evt.Kind)
case <-time.After(50 * time.Millisecond):
}
al.RuntimeEventBus().Publish(context.Background(), runtimeevents.Event{
Kind: runtimeevents.KindAgentTurnStart,
Source: runtimeevents.Source{Component: "agent", Name: "main"},
Scope: runtimeevents.Scope{
AgentID: "main",
TurnID: "turn-1",
SessionKey: "session-1",
Channel: "telegram",
Account: "bot-1",
ChatID: "chat-1",
ChatType: "private",
TopicID: "topic-1",
SpaceID: "space-1",
SpaceType: "dm",
SenderID: "sender-1",
MessageID: "message-1",
},
Payload: TurnStartPayload{UserMessage: "hello"},
})
evt := waitForEvent(t, sub.C, 2*time.Second, nil)
if evt.Kind != EventKindTurnStart {
t.Fatalf("event kind = %q, want %q", evt.Kind, EventKindTurnStart)
}
if evt.Context == nil || evt.Context.Inbound == nil {
t.Fatalf("expected legacy event inbound context, got %#v", evt.Context)
}
if got := evt.Context.Inbound.Channel; got != "telegram" {
t.Fatalf("inbound channel = %q, want telegram", got)
}
if got := evt.Context.Inbound.ChatID; got != "chat-1" {
t.Fatalf("inbound chat_id = %q, want chat-1", got)
}
if got := evt.Context.Inbound.MessageID; got != "message-1" {
t.Fatalf("inbound message_id = %q, want message-1", got)
}
}

View file

@ -6,6 +6,9 @@ import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"sort"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
@ -17,6 +20,89 @@ import (
"github.com/sipeed/picoclaw/pkg/utils"
)
func toolErrorSummary(result *tools.ToolResult) string {
if result == nil || !result.IsError {
return ""
}
content := strings.TrimSpace(result.ContentForLLM())
if content == "" && result.Err != nil {
content = strings.TrimSpace(result.Err.Error())
}
return utils.Truncate(content, 200)
}
func inferSkillNamesFromToolCall(ts *turnState, toolName string, toolArgs map[string]any) []string {
if ts == nil || toolName != "read_file" {
return nil
}
rawPath, ok := toolArgs["path"].(string)
if !ok {
return nil
}
path := strings.TrimSpace(rawPath)
if path == "" {
return nil
}
cleanPath := filepath.Clean(path)
if !filepath.IsAbs(cleanPath) {
cleanPath = filepath.Join(ts.workspace, cleanPath)
}
if filepath.Base(cleanPath) != "SKILL.md" {
return nil
}
var roots []string
if ts.agent != nil && ts.agent.ContextBuilder != nil {
roots = ts.agent.ContextBuilder.skillRoots()
}
if len(roots) == 0 && strings.TrimSpace(ts.workspace) != "" {
roots = []string{filepath.Join(ts.workspace, "skills")}
}
found := make(map[string]struct{})
for _, root := range roots {
root = strings.TrimSpace(root)
if root == "" {
continue
}
rel, err := filepath.Rel(filepath.Clean(root), cleanPath)
if err != nil {
continue
}
if rel == "." || rel == "" || strings.HasPrefix(rel, "..") {
continue
}
parts := strings.Split(rel, string(filepath.Separator))
if len(parts) != 2 || parts[1] != "SKILL.md" {
continue
}
skillName := strings.TrimSpace(parts[0])
if skillName == "" {
continue
}
if ts.agent != nil && ts.agent.ContextBuilder != nil {
if canonical, ok := ts.agent.ContextBuilder.ResolveSkillName(skillName); ok {
skillName = canonical
}
}
found[skillName] = struct{}{}
}
if len(found) == 0 {
return nil
}
names := make([]string, 0, len(found))
for skillName := range found {
names = append(names, skillName)
}
sort.Strings(names)
return names
}
// ExecuteTools executes the tool loop, handling BeforeTool/ApproveTool/AfterTool hooks,
// tool execution with async callbacks, media delivery, and steering injection.
// Returns ToolControl indicating what the coordinator should do next:
@ -203,6 +289,12 @@ toolLoop:
Async: hookResult.Async,
},
)
ts.recordToolExecution(
toolName,
!hookResult.IsError,
toolErrorSummary(hookResult),
inferSkillNamesFromToolCall(ts, toolName, toolArgs),
)
messages = append(messages, toolResultMsg)
if !ts.opts.NoHistory {
@ -579,6 +671,12 @@ toolLoop:
Async: toolResult.Async,
},
)
ts.recordToolExecution(
toolName,
!toolResult.IsError,
toolErrorSummary(toolResult),
inferSkillNamesFromToolCall(ts, toolName, toolArgs),
)
messages = append(messages, toolResultMsg)
if !ts.opts.NoHistory {
ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg)

View file

@ -0,0 +1,50 @@
package agent
import (
"os"
"path/filepath"
"testing"
)
func TestInferSkillNamesFromToolCall_ReadFileSkillMarkdown(t *testing.T) {
workspace := t.TempDir()
skillDir := filepath.Join(workspace, "skills", "three-one")
if err := os.MkdirAll(skillDir, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(
filepath.Join(skillDir, "SKILL.md"),
[]byte("---\nname: three-one\ndescription: test\n---\n# Three One\n"),
0o644,
); err != nil {
t.Fatalf("WriteFile: %v", err)
}
cb := NewContextBuilder(workspace)
ts := &turnState{
workspace: workspace,
agent: &AgentInstance{
Workspace: workspace,
ContextBuilder: cb,
},
}
got := inferSkillNamesFromToolCall(ts, "read_file", map[string]any{
"path": filepath.Join(workspace, "skills", "three-one", "SKILL.md"),
})
if len(got) != 1 || got[0] != "three-one" {
t.Fatalf("inferSkillNamesFromToolCall = %v, want [three-one]", got)
}
}
func TestInferSkillNamesFromToolCall_NonSkillFileIgnored(t *testing.T) {
workspace := t.TempDir()
ts := &turnState{workspace: workspace}
got := inferSkillNamesFromToolCall(ts, "read_file", map[string]any{
"path": filepath.Join(workspace, "README.md"),
})
if len(got) != 0 {
t.Fatalf("inferSkillNamesFromToolCall = %v, want empty", got)
}
}

View file

@ -364,9 +364,14 @@ func (p *Pipeline) CallLLM(
exec.history = asmResp.History
exec.summary = asmResp.Summary
}
exec.messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(
promptBuildRequestForTurn(ts, exec.history, exec.summary, "", nil),
)
contextualSkills := ts.activeSkills
if ts.agent.ContextBuilder != nil {
contextualSkills = ts.agent.ContextBuilder.ResolveActiveSkillsForContext(ts.activeSkills)
}
ts.recordSkillContextSnapshot(skillContextTriggerContextRetryRebuild, contextualSkills)
rebuildPromptReq := promptBuildRequestForTurn(ts, exec.history, exec.summary, "", nil)
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
exec.messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
exec.callMessages = exec.messages
if exec.gracefulTerminal {
msgs := append([]providers.Message(nil), exec.messages...)

View file

@ -31,9 +31,14 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
}
ts.captureRestorePoint(history, summary)
messages := ts.agent.ContextBuilder.BuildMessagesFromPrompt(
promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media),
)
contextualSkills := ts.activeSkills
if ts.agent.ContextBuilder != nil {
contextualSkills = ts.agent.ContextBuilder.ResolveActiveSkillsForContext(ts.activeSkills)
}
ts.recordSkillContextSnapshot(skillContextTriggerInitialBuild, contextualSkills)
initialPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media)
initialPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
messages := ts.agent.ContextBuilder.BuildMessagesFromPrompt(initialPromptReq)
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
@ -61,9 +66,9 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
history = resp.History
summary = resp.Summary
}
messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(
promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media),
)
rebuildPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media)
rebuildPromptReq.ActiveSkills = append([]string(nil), contextualSkills...)
messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(rebuildPromptReq)
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
}
}

View file

@ -52,6 +52,7 @@ const (
PromptSourceMemory PromptSourceID = "memory:workspace"
PromptSourceSkillCatalog PromptSourceID = "skill:index"
PromptSourceActiveSkills PromptSourceID = "skill:active"
PromptSourceAgentDiscovery PromptSourceID = "agent:discovery"
PromptSourceToolRegistry PromptSourceID = "tool_registry:native"
PromptSourceToolDiscovery PromptSourceID = "tool_registry:discovery"
PromptSourceOutputPolicy PromptSourceID = "runtime.output"
@ -195,6 +196,13 @@ func builtinPromptSources() []PromptSourceDescriptor {
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotActiveSkill}},
StableByDefault: false,
},
{
ID: PromptSourceAgentDiscovery,
Owner: "agent",
Description: "Structured multi-agent discovery registry",
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}},
StableByDefault: false,
},
{
ID: PromptSourceMemory,
Owner: "memory",

View file

@ -93,6 +93,47 @@ func (c mcpServerPromptContributor) ContributePrompt(
}, nil
}
type agentDiscoveryPromptContributor struct {
agentID string
discover func(agentID string) []AgentDescriptor
}
func (c agentDiscoveryPromptContributor) PromptSource() PromptSourceDescriptor {
return PromptSourceDescriptor{
ID: PromptSourceAgentDiscovery,
Owner: "agent",
Description: "Structured multi-agent discovery registry",
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}},
StableByDefault: false,
}
}
func (c agentDiscoveryPromptContributor) ContributePrompt(
_ context.Context,
_ PromptBuildRequest,
) ([]PromptPart, error) {
if c.discover == nil {
return nil, nil
}
content := formatAgentDiscoverySection(c.discover(c.agentID))
if strings.TrimSpace(content) == "" {
return nil, nil
}
return []PromptPart{
{
ID: "capability.agent_discovery",
Layer: PromptLayerCapability,
Slot: PromptSlotTooling,
Source: PromptSource{ID: PromptSourceAgentDiscovery, Name: "agent:discovery"},
Title: "agent discovery",
Content: content,
Stable: false,
Cache: PromptCacheNone,
},
}, nil
}
func mcpPromptSourceID(serverName string) PromptSourceID {
return PromptSourceID("mcp:" + promptSourceComponent(serverName))
}

View file

@ -13,6 +13,7 @@ import (
// AgentRegistry manages multiple agent instances and routes messages to them.
type AgentRegistry struct {
cfg *config.Config
agents map[string]*AgentInstance
resolver *routing.RouteResolver
mu sync.RWMutex
@ -24,6 +25,7 @@ func NewAgentRegistry(
provider providers.LLMProvider,
) *AgentRegistry {
registry := &AgentRegistry{
cfg: cfg,
agents: make(map[string]*AgentInstance),
resolver: routing.NewRouteResolver(cfg),
}
@ -53,6 +55,12 @@ func NewAgentRegistry(
}
}
for _, instance := range registry.agents {
if instance.ContextBuilder != nil {
instance.ContextBuilder.WithAgentDiscovery(instance.ID, registry.ListSpawnableAgents)
}
}
return registry
}
@ -81,16 +89,43 @@ func (r *AgentRegistry) ListAgentIDs() []string {
return ids
}
func (r *AgentRegistry) allowedMCPServers() map[string]struct{} {
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.agents) == 0 {
return nil
}
union := make(map[string]struct{})
for _, agent := range r.agents {
if agent == nil {
continue
}
if agent.MCPServerAllowlist == nil {
return nil
}
for serverName := range agent.MCPServerAllowlist {
union[serverName] = struct{}{}
}
}
return union
}
// CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID.
func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool {
parent, ok := r.GetAgent(parentAgentID)
if !ok {
return false
}
if parent.Subagents == nil || parent.Subagents.AllowAgents == nil {
return agentAllowsSubagent(parent, routing.NormalizeAgentID(targetAgentID))
}
func agentAllowsSubagent(parent *AgentInstance, targetNorm string) bool {
if parent == nil || parent.Subagents == nil || parent.Subagents.AllowAgents == nil {
return false
}
targetNorm := routing.NormalizeAgentID(targetAgentID)
for _, allowed := range parent.Subagents.AllowAgents {
if allowed == "*" {
return true
@ -102,6 +137,14 @@ func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bo
return false
}
func agentHasSpawnTool(agent *AgentInstance) bool {
if agent == nil || agent.Tools == nil {
return false
}
_, ok := agent.Tools.Get("spawn")
return ok
}
// ForEachTool calls fn for every tool registered under the given name
// across all agents. This is useful for propagating dependencies (e.g.
// MediaStore) to tools after registry construction.
@ -131,11 +174,13 @@ func (r *AgentRegistry) Close() {
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
r.mu.RLock()
defer r.mu.RUnlock()
if agent, ok := r.agents["main"]; ok {
return agent
if id := r.defaultAgentIDLocked(); id != "" {
if agent, ok := r.agents[id]; ok {
return agent
}
}
for _, agent := range r.agents {
return agent
for id := range r.agents {
return r.agents[id]
}
return nil
}

View file

@ -2,8 +2,10 @@ package agent
import (
"context"
"slices"
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
@ -200,6 +202,112 @@ func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) {
agent, _ := registry.GetAgent("no-fallback")
if len(agent.Fallbacks) != 0 {
t.Errorf("expected 0 fallbacks (explicit empty), got %d: %v", len(agent.Fallbacks), agent.Fallbacks)
t.Errorf(
"expected 0 fallbacks (explicit empty), got %d: %v",
len(agent.Fallbacks),
agent.Fallbacks,
)
}
}
func TestNewAgentLoop_AgentToolAllowlistFiltersRuntimeTools(t *testing.T) {
mainWorkspace := setupWorkspace(t, map[string]string{
"AGENT.md": "# Agent\nMain agent.\n",
})
defer cleanupWorkspace(t, mainWorkspace)
researchWorkspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
tools: [read_file, write_file, web_search, web_fetch, message]
skills: [deep-research]
---
# Agent
Research agent.
`,
})
defer cleanupWorkspace(t, researchWorkspace)
cfg := testCfg([]config.AgentConfig{
{ID: "main", Default: true, Workspace: mainWorkspace},
{
ID: "research",
Workspace: researchWorkspace,
},
})
cfg.Agents.Defaults.Workspace = mainWorkspace
cfg.Tools.ReadFile.Enabled = true
cfg.Tools.WriteFile.Enabled = true
cfg.Tools.ListDir.Enabled = true
cfg.Tools.Exec.Enabled = true
cfg.Tools.Message.Enabled = true
cfg.Tools.Web.Enabled = true
cfg.Tools.Web.DuckDuckGo.Enabled = true
cfg.Tools.WebFetch.Enabled = true
cfg.Tools.Spawn.Enabled = true
cfg.Tools.Subagent.Enabled = true
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{})
defer al.Close()
research, ok := al.GetRegistry().GetAgent("research")
if !ok || research == nil {
t.Fatal("expected research agent")
}
got := research.Tools.List()
want := []string{"message", "read_file", "web_fetch", "web_search", "write_file"}
if !slices.Equal(got, want) {
t.Fatalf("research tools = %v, want %v", got, want)
}
for _, blocked := range []string{"exec", "list_dir", "spawn", "subagent"} {
if _, ok := research.Tools.Get(blocked); ok {
t.Fatalf("expected %q to be blocked by allowlist", blocked)
}
}
}
func TestNewAgentLoop_AgentToolAllowlistRequiresExactRuntimeToolNames(t *testing.T) {
mainWorkspace := setupWorkspace(t, map[string]string{
"AGENT.md": "# Agent\nMain agent.\n",
})
defer cleanupWorkspace(t, mainWorkspace)
researchWorkspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
tools: [web]
---
# Agent
Research agent.
`,
})
defer cleanupWorkspace(t, researchWorkspace)
cfg := testCfg([]config.AgentConfig{
{ID: "main", Default: true, Workspace: mainWorkspace},
{
ID: "research",
Workspace: researchWorkspace,
},
})
cfg.Agents.Defaults.Workspace = mainWorkspace
cfg.Tools.Web.Enabled = true
cfg.Tools.Web.DuckDuckGo.Enabled = true
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockRegistryProvider{})
defer al.Close()
research, ok := al.GetRegistry().GetAgent("research")
if !ok || research == nil {
t.Fatal("expected research agent")
}
if _, ok := research.Tools.Get("web_search"); ok {
t.Fatal("web_search should not be registered when allowlist contains only web")
}
if slices.Contains(research.Tools.List(), "web_search") {
t.Fatalf("research tools = %v, expected web_search to be absent", research.Tools.List())
}
}

View file

@ -156,6 +156,18 @@ func (sq *steeringQueue) lenScope(scope string) int {
return len(sq.queues[normalizeSteeringScope(scope)])
}
func (sq *steeringQueue) clearScope(scope string) int {
sq.mu.Lock()
defer sq.mu.Unlock()
scope = normalizeSteeringScope(scope)
count := len(sq.queues[scope])
if count > 0 {
delete(sq.queues, scope)
}
return count
}
// setMode updates the steering mode.
func (sq *steeringQueue) setMode(mode SteeringMode) {
sq.mu.Lock()
@ -290,6 +302,13 @@ func (al *AgentLoop) pendingSteeringCountForScope(scope string) int {
return al.steering.lenScope(scope)
}
func (al *AgentLoop) clearSteeringMessagesForScope(scope string) int {
if al.steering == nil {
return 0
}
return al.steering.clearScope(scope)
}
func (al *AgentLoop) continueWithSteeringMessages(
ctx context.Context,
agent *AgentInstance,
@ -511,6 +530,10 @@ func (al *AgentLoop) HardAbort(sessionKey string) error {
"initial_history_length": ts.initialHistoryLength,
})
// Cancel the active provider/tool turn contexts immediately so long-running
// execution stops as soon as possible on the root turn.
_ = ts.requestHardAbort()
// IMPORTANT: Trigger cascading cancellation FIRST to stop all child SubTurns
// from adding more messages to the session. This prevents race conditions
// where rollback happens while children are still writing.

View file

@ -12,6 +12,7 @@ import (
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/audio/asr"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
@ -477,6 +478,16 @@ func (p *lateSteeringProvider) GetDefaultModel() string {
return "late-steering-mock"
}
type fixedTranscriber struct {
text string
}
func (f *fixedTranscriber) Name() string { return "fixed" }
func (f *fixedTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*asr.TranscriptionResponse, error) {
return &asr.TranscriptionResponse{Text: f.text}, nil
}
type blockingDirectProvider struct {
mu sync.Mutex
calls int
@ -840,6 +851,307 @@ func TestAgentLoop_Run_AutoContinuesLateSteeringMessage(t *testing.T) {
}
}
func TestAgentLoop_Run_QueuedVoiceMessageIsTranscribedBeforeSteering(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
provider := &lateSteeringProvider{
firstCallStarted: make(chan struct{}),
releaseFirstCall: make(chan struct{}),
}
al := NewAgentLoop(cfg, msgBus, provider)
store := media.NewFileMediaStore()
audioPath := filepath.Join(tmpDir, "voice.ogg")
if err := os.WriteFile(audioPath, []byte("fake audio"), 0o644); err != nil {
t.Fatalf("write audio fixture: %v", err)
}
ref, err := store.Store(audioPath, media.MediaMeta{
Filename: "voice.ogg",
ContentType: "audio/ogg",
CleanupPolicy: media.CleanupPolicyForgetOnly,
}, "scope-voice")
if err != nil {
t.Fatalf("store audio fixture: %v", err)
}
al.SetMediaStore(store)
al.SetTranscriber(&fixedTranscriber{text: "and also two pieces of bread"})
runCtx, cancelRun := context.WithCancel(context.Background())
defer cancelRun()
runErrCh := make(chan error, 1)
go func() {
runErrCh <- al.Run(runCtx)
}()
first := bus.InboundMessage{
Context: bus.InboundContext{
Channel: "test",
ChatID: "chat1",
ChatType: "direct",
SenderID: "user1",
},
Content: "first meal",
}
late := bus.InboundMessage{
Context: bus.InboundContext{
Channel: "test",
ChatID: "chat1",
ChatType: "direct",
SenderID: "user1",
},
Content: "[voice]",
Media: []string{ref},
}
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 voice inbound: %v", err)
}
close(provider.releaseFirstCall)
subCtx, subCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer subCancel()
select {
case <-msgBus.OutboundChan():
case <-subCtx.Done():
t.Fatal("expected outbound response")
}
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()
secondMessages := append([]providers.Message(nil), provider.secondCallMessages...)
provider.mu.Unlock()
foundTranscribedVoice := false
for _, msg := range secondMessages {
if msg.Role == "user" && strings.Contains(msg.Content, "[voice: and also two pieces of bread]") {
foundTranscribedVoice = true
break
}
}
if !foundTranscribedVoice {
t.Fatalf("expected queued voice message to be transcribed before steering injection, got %#v", secondMessages)
}
}
func TestAgentLoop_Run_PendingStopStillContinuesQueuedFollowUp(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,
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
MaxParallelTurns: 1,
},
},
}
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)
}()
defer func() {
cancelRun()
select {
case err := <-runErrCh:
if err != nil {
t.Fatalf("Run() error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for Run to stop")
}
}()
blockerSessionKey := session.BuildOpaqueSessionKey("agent:main:test:blocker")
targetSessionKey := session.BuildOpaqueSessionKey("agent:main:test:target")
blockerCtx := bus.InboundContext{
Channel: "test",
ChatID: "blocker-chat",
ChatType: "direct",
SenderID: "user1",
}
targetCtx := bus.InboundContext{
Channel: "test",
ChatID: "target-chat",
ChatType: "direct",
SenderID: "user1",
}
if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{
Context: blockerCtx,
Content: "block worker pool",
SessionKey: blockerSessionKey,
}); err != nil {
t.Fatalf("PublishInbound(blocker) error = %v", err)
}
select {
case <-provider.firstCallStarted:
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for blocker turn to start")
}
if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{
Context: targetCtx,
Content: "skip this turn",
SessionKey: targetSessionKey,
}); err != nil {
t.Fatalf("PublishInbound(target start) error = %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for {
ts := al.getActiveTurnState(targetSessionKey)
if ts != nil && strings.HasPrefix(ts.turnID, pendingTurnPrefix) {
break
}
if time.Now().After(deadline) {
t.Fatal("timeout waiting for pending placeholder")
}
time.Sleep(10 * time.Millisecond)
}
if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{
Context: targetCtx,
Content: "/stop",
SessionKey: targetSessionKey,
}); err != nil {
t.Fatalf("PublishInbound(/stop) error = %v", err)
}
deadline = time.Now().Add(2 * time.Second)
stopSeen := false
for !stopSeen {
select {
case outbound := <-msgBus.OutboundChan():
if outbound.ChatID == "target-chat" && outbound.Content == "Task stopped. Current task was canceled." {
stopSeen = true
}
case <-time.After(10 * time.Millisecond):
if time.Now().After(deadline) {
t.Fatal("timeout waiting for /stop reply")
}
}
}
if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{
Context: targetCtx,
Content: "run this instead",
SessionKey: targetSessionKey,
}); err != nil {
t.Fatalf("PublishInbound(follow-up) error = %v", err)
}
deadline = time.Now().Add(2 * time.Second)
for al.pendingSteeringCountForScope(targetSessionKey) == 0 {
if time.Now().After(deadline) {
t.Fatal("timeout waiting for follow-up to enter scoped steering queue")
}
time.Sleep(10 * time.Millisecond)
}
close(provider.releaseFirstCall)
deadline = time.Now().Add(5 * time.Second)
followUpSeen := false
for !followUpSeen {
select {
case outbound := <-msgBus.OutboundChan():
if outbound.ChatID == "target-chat" && outbound.Content == "continued response" {
followUpSeen = true
}
case <-time.After(10 * time.Millisecond):
if time.Now().After(deadline) {
t.Fatal("timeout waiting for queued follow-up continuation")
}
}
}
deadline = time.Now().Add(2 * time.Second)
for {
if al.GetActiveTurnBySession(targetSessionKey) == nil &&
al.pendingSteeringCountForScope(targetSessionKey) == 0 {
break
}
if time.Now().After(deadline) {
t.Fatal("timeout waiting for target session to go idle")
}
time.Sleep(10 * time.Millisecond)
}
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 (blocker + continuation), got %d", calls)
}
foundFollowUp := false
for _, msg := range secondMessages {
if msg.Role == "user" && msg.Content == "run this instead" {
foundFollowUp = true
}
if msg.Role == "user" && msg.Content == "skip this turn" {
t.Fatalf("unexpected canceled message in continuation context: %q", msg.Content)
}
}
if !foundFollowUp {
t.Fatal("expected queued follow-up to be processed after pending stop")
}
}
func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
@ -1392,6 +1704,149 @@ func TestAgentLoop_InterruptHard_RestoresSession(t *testing.T) {
}
}
func TestAgentLoop_StopCommand_AbortsActiveTurnAndClearsQueuedSteering(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,
ModelName: "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 continue",
}
al := NewAgentLoop(cfg, msgBus, provider)
started := make(chan struct{})
al.RegisterTool(&interruptibleTool{name: "cancel_tool", started: started})
sessionKey := session.BuildMainSessionKey(routing.DefaultAgentID)
runCtx, cancelRun := context.WithCancel(context.Background())
defer cancelRun()
runErrCh := make(chan error, 1)
go func() {
runErrCh <- al.Run(runCtx)
}()
defer func() {
cancelRun()
select {
case err := <-runErrCh:
if err != nil {
t.Fatalf("Run() error = %v", err)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for Run to stop")
}
}()
baseMsg := testInboundMessage(bus.InboundMessage{
Context: bus.InboundContext{
Channel: "test",
ChatID: "chat1",
ChatType: "direct",
SenderID: "user1",
},
SessionKey: sessionKey,
})
if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{
Context: baseMsg.Context,
Content: "do work",
SessionKey: sessionKey,
}); err != nil {
t.Fatalf("PublishInbound(start) error = %v", err)
}
select {
case <-started:
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for interruptible tool to start")
}
if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{
Context: baseMsg.Context,
Content: "follow up after cancel",
SessionKey: sessionKey,
}); err != nil {
t.Fatalf("PublishInbound(follow-up) error = %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for al.pendingSteeringCountForScope(sessionKey) == 0 {
if time.Now().After(deadline) {
t.Fatal("timeout waiting for follow-up message to enter steering queue")
}
time.Sleep(10 * time.Millisecond)
}
if err := msgBus.PublishInbound(context.Background(), bus.InboundMessage{
Context: baseMsg.Context,
Content: "/stop",
SessionKey: sessionKey,
}); err != nil {
t.Fatalf("PublishInbound(/stop) error = %v", err)
}
select {
case outbound := <-msgBus.OutboundChan():
want := "Task stopped. \"do work\" was canceled."
if outbound.Content != want {
t.Fatalf("stop reply = %q, want %q", outbound.Content, want)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for /stop reply")
}
deadline = time.Now().Add(5 * time.Second)
for al.GetActiveTurnBySession(sessionKey) != nil {
if time.Now().After(deadline) {
t.Fatal("timeout waiting for active turn to stop")
}
time.Sleep(10 * time.Millisecond)
}
if got := al.pendingSteeringCountForScope(sessionKey); got != 0 {
t.Fatalf("expected cleared steering queue, got %d pending message(s)", got)
}
select {
case outbound := <-msgBus.OutboundChan():
t.Fatalf("unexpected outbound after stop: %q", outbound.Content)
case <-time.After(300 * time.Millisecond):
}
provider.mu.Lock()
calls := provider.calls
provider.mu.Unlock()
if calls != 1 {
t.Fatalf("expected provider to stop before follow-up turn, got %d calls", calls)
}
}
// capturingMockProvider captures messages sent to Chat for inspection.
type capturingMockProvider struct {
response string

View file

@ -174,7 +174,10 @@ type SubTurnConfig struct {
// Used by team tool to enforce token limits across all team members.
InitialTokenBudget *atomic.Int64
// Can be extended with temperature, topP, etc.
// TargetAgentID, when set, runs the sub-turn as the specified agent.
// The target agent's workspace, model, tools, and system prompt are used
// instead of the caller's. If empty, the sub-turn runs as the parent agent.
TargetAgentID string
}
// ====================== Context Keys ======================
@ -232,6 +235,7 @@ func (s *AgentLoopSpawner) SpawnSubTurn(
Critical: cfg.Critical,
Timeout: cfg.Timeout,
MaxContextRunes: cfg.MaxContextRunes,
TargetAgentID: cfg.TargetAgentID,
}
return spawnSubTurn(ctx, s.al, parentTS, agentCfg)
@ -314,8 +318,9 @@ func spawnSubTurn(
return nil, ErrDepthLimitExceeded
}
// 2. Config validation
if cfg.Model == "" {
// 2. Config validation: Model is required unless TargetAgentID is set
// (the target agent provides its own model).
if cfg.Model == "" && cfg.TargetAgentID == "" {
return nil, ErrInvalidSubTurnConfig
}
@ -333,12 +338,22 @@ func spawnSubTurn(
childID := al.generateSubTurnID()
// Get the agent instance from parent, falling back to the default agent.
// Wrap it in a shallow copy that uses an ephemeral (in-memory only) session store
// so that child turns never pollute or persist to the parent's session history.
baseAgent := parentTS.agent
if baseAgent == nil {
baseAgent = al.registry.GetDefaultAgent()
// Resolve the agent instance for the child turn.
// When TargetAgentID is set, look up that agent from the registry so the
// child runs with the target's workspace, model, tools, and system prompt.
// Otherwise fall back to the parent's agent (existing behavior).
var baseAgent *AgentInstance
if cfg.TargetAgentID != "" {
var ok bool
baseAgent, ok = al.registry.GetAgent(cfg.TargetAgentID)
if !ok {
return nil, fmt.Errorf("target agent %q not found in registry", cfg.TargetAgentID)
}
} else {
baseAgent = parentTS.agent
if baseAgent == nil {
baseAgent = al.registry.GetDefaultAgent()
}
}
if baseAgent == nil {
return nil, errors.New("parent turnState has no agent instance")

View file

@ -4,6 +4,9 @@ import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@ -2122,3 +2125,206 @@ func TestSubTurn_IndependentContext(t *testing.T) {
t.Log("✓ SubTurn completed successfully (independent context)")
}
}
// ====================== TargetAgentID Tests ======================
// modelRecordingProvider captures the model passed to Chat for test assertions.
type modelRecordingProvider struct {
mu sync.Mutex
lastModel string
}
func (rp *modelRecordingProvider) Chat(
_ context.Context,
_ []providers.Message,
_ []providers.ToolDefinition,
model string,
_ map[string]any,
) (*providers.LLMResponse, error) {
rp.mu.Lock()
rp.lastModel = model
rp.mu.Unlock()
return &providers.LLMResponse{Content: "Mock response"}, nil
}
func (rp *modelRecordingProvider) GetDefaultModel() string { return "mock-model" }
func (rp *modelRecordingProvider) getLastModel() string {
rp.mu.Lock()
defer rp.mu.Unlock()
return rp.lastModel
}
// newMultiAgentLoop creates an AgentLoop with two named agents for testing
// cross-agent delegation via TargetAgentID.
func newMultiAgentLoop(t *testing.T, provider providers.LLMProvider) (*AgentLoop, func()) {
t.Helper()
tmpDir, err := os.MkdirTemp("", "multiagent-test-*")
if err != nil {
t.Fatalf("create temp dir: %v", err)
}
alphaDir := filepath.Join(tmpDir, "alpha")
betaDir := filepath.Join(tmpDir, "beta")
os.MkdirAll(alphaDir, 0o755)
os.MkdirAll(betaDir, 0o755)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "default-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
List: []config.AgentConfig{
{
ID: "alpha",
Workspace: alphaDir,
Model: &config.AgentModelConfig{Primary: "model-alpha"},
},
{
ID: "beta",
Workspace: betaDir,
Model: &config.AgentModelConfig{Primary: "model-beta"},
},
},
},
}
msgBus := bus.NewMessageBus()
al := NewAgentLoop(cfg, msgBus, provider)
return al, func() { os.RemoveAll(tmpDir) }
}
func TestSpawnSubTurn_TargetAgentID_UsesTargetAgent(t *testing.T) {
rp := &modelRecordingProvider{}
al, cleanup := newMultiAgentLoop(t, rp)
defer cleanup()
alphaAgent, ok := al.registry.GetAgent("alpha")
if !ok {
t.Fatal("alpha agent not in registry")
}
// Parent is alpha, target is beta
parent := &turnState{
ctx: context.Background(),
turnID: "parent-alpha",
depth: 0,
childTurnIDs: []string{},
pendingResults: make(chan *tools.ToolResult, 4),
concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns),
session: &ephemeralSessionStore{},
agent: alphaAgent,
}
result, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{
TargetAgentID: "beta",
SystemPrompt: "task for beta",
})
if err != nil {
t.Fatalf("spawnSubTurn failed: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
// The recording provider captures the model passed to Chat().
// If TargetAgentID works correctly, the child turn should have
// used beta's model, not alpha's.
if got := rp.getLastModel(); got != "model-beta" {
t.Errorf("child turn used model %q, want %q", got, "model-beta")
}
}
func TestSpawnSubTurn_TargetAgentID_NotFound(t *testing.T) {
al, cleanup := newMultiAgentLoop(t, &mockProvider{})
defer cleanup()
alphaAgent, _ := al.registry.GetAgent("alpha")
parent := &turnState{
ctx: context.Background(),
turnID: "parent-alpha",
depth: 0,
childTurnIDs: []string{},
pendingResults: make(chan *tools.ToolResult, 4),
concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns),
session: &ephemeralSessionStore{},
agent: alphaAgent,
}
_, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{
TargetAgentID: "nonexistent",
SystemPrompt: "task",
})
if err == nil {
t.Fatal("expected error for nonexistent agent")
}
if !strings.Contains(err.Error(), "not found") {
t.Errorf("error should mention 'not found', got: %v", err)
}
}
func TestSpawnSubTurn_TargetAgentID_EmptyModelAccepted(t *testing.T) {
al, cleanup := newMultiAgentLoop(t, &mockProvider{})
defer cleanup()
alphaAgent, _ := al.registry.GetAgent("alpha")
parent := &turnState{
ctx: context.Background(),
turnID: "parent-alpha",
depth: 0,
childTurnIDs: []string{},
pendingResults: make(chan *tools.ToolResult, 4),
concurrencySem: make(chan struct{}, testMaxConcurrentSubTurns),
session: &ephemeralSessionStore{},
agent: alphaAgent,
}
// Model is empty but TargetAgentID is set — should NOT fail validation
result, err := spawnSubTurn(context.Background(), al, parent, SubTurnConfig{
Model: "", // intentionally empty
TargetAgentID: "beta",
SystemPrompt: "task for beta",
})
if err != nil {
t.Fatalf("should accept empty Model when TargetAgentID is set, got: %v", err)
}
if result == nil {
t.Fatal("expected non-nil result")
}
}
func TestDelegateToolNotRegistered_SingleAgent(t *testing.T) {
// Single-agent setup: delegate should not be registered
al, _, _, provider, cleanup := newTestAgentLoop(t)
_ = provider
defer cleanup()
agent := al.registry.GetDefaultAgent()
if agent == nil {
t.Fatal("default agent should exist")
}
if _, has := agent.Tools.Get("delegate"); has {
t.Error("delegate tool should not be registered in single-agent setup")
}
}
func TestDelegateToolRegistered_MultiAgent(t *testing.T) {
al, cleanup := newMultiAgentLoop(t, &mockProvider{})
defer cleanup()
// Both agents should have the delegate tool
for _, id := range []string{"alpha", "beta"} {
agent, ok := al.registry.GetAgent(id)
if !ok {
t.Fatalf("agent %q not found", id)
}
if _, has := agent.Tools.Get("delegate"); !has {
t.Errorf("agent %q should have delegate tool in multi-agent setup", id)
}
}
}

203
pkg/agent/tool_allowlist.go Normal file
View file

@ -0,0 +1,203 @@
package agent
import (
"sort"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/tools"
)
const dynamicMCPToolPrefix = "mcp_"
func normalizeMCPServerName(name string) string {
return strings.ToLower(strings.TrimSpace(name))
}
func normalizedMCPServerNameSet(
servers map[string]config.MCPServerConfig,
) map[string]struct{} {
normalized := make(map[string]struct{}, len(servers))
for serverName := range servers {
name := normalizeMCPServerName(serverName)
if name == "" {
continue
}
normalized[name] = struct{}{}
}
return normalized
}
func warnOnUnknownAgentToolDeclarations(
agentID, workspace string,
definition AgentContextDefinition,
registry *tools.ToolRegistry,
) {
if registry == nil || frontmatterParseFailed(definition) {
return
}
if unknownTools := unknownAgentToolNames(registry, definition); len(unknownTools) > 0 {
logger.WarnCF("agent", "AGENT.md declares unregistered tool names",
map[string]any{
"agent_id": agentID,
"workspace": workspace,
"tools": unknownTools,
})
}
}
func warnOnUnknownAgentMCPServerDeclarations(
agentID, workspace string,
cfg *config.Config,
definition AgentContextDefinition,
) {
if cfg == nil || frontmatterParseFailed(definition) {
return
}
if unknownServers := unknownAgentMCPServerNames(cfg, definition); len(unknownServers) > 0 {
logger.WarnCF("agent", "AGENT.md declares unknown MCP server names",
map[string]any{
"agent_id": agentID,
"workspace": workspace,
"mcp_servers": unknownServers,
})
}
}
func unknownAgentToolNames(
registry *tools.ToolRegistry,
definition AgentContextDefinition,
) []string {
if definition.Agent == nil || definition.Agent.Frontmatter.Tools == nil {
return nil
}
known := registeredRuntimeToolNames(registry)
unknown := make(map[string]struct{})
for _, raw := range definition.Agent.Frontmatter.Tools {
name := strings.ToLower(strings.TrimSpace(raw))
if name == "" || strings.HasPrefix(name, dynamicMCPToolPrefix) {
continue
}
if _, ok := known[name]; ok {
continue
}
unknown[name] = struct{}{}
}
return sortedKeys(unknown)
}
func registeredRuntimeToolNames(registry *tools.ToolRegistry) map[string]struct{} {
known := make(map[string]struct{})
if registry == nil {
return known
}
for _, raw := range registry.List() {
name := strings.ToLower(strings.TrimSpace(raw))
if name == "" {
continue
}
known[name] = struct{}{}
}
return known
}
func unknownAgentMCPServerNames(cfg *config.Config, definition AgentContextDefinition) []string {
if cfg == nil || definition.Agent == nil || definition.Agent.Frontmatter.MCPServers == nil {
return nil
}
knownServers := normalizedMCPServerNameSet(cfg.Tools.MCP.Servers)
unknown := make(map[string]struct{})
for _, raw := range definition.Agent.Frontmatter.MCPServers {
name := normalizeMCPServerName(raw)
if name == "" {
continue
}
if _, ok := knownServers[name]; ok {
continue
}
unknown[name] = struct{}{}
}
return sortedKeys(unknown)
}
func sortedKeys(values map[string]struct{}) []string {
if len(values) == 0 {
return nil
}
result := make([]string, 0, len(values))
for value := range values {
result = append(result, value)
}
sort.Strings(result)
return result
}
func resolveAgentToolAllowlist(definition AgentContextDefinition) []string {
if frontmatterParseFailed(definition) {
return []string{}
}
if definition.Agent == nil || !frontmatterDeclaresField(definition, "tools") {
return nil
}
allowlist := make(map[string]struct{}, len(definition.Agent.Frontmatter.Tools))
for _, raw := range definition.Agent.Frontmatter.Tools {
trimmed := strings.ToLower(strings.TrimSpace(raw))
if trimmed == "" {
continue
}
allowlist[trimmed] = struct{}{}
}
if len(allowlist) == 0 {
return []string{}
}
return sortedKeys(allowlist)
}
func resolveAgentMCPServerAllowlist(definition AgentContextDefinition) map[string]struct{} {
if frontmatterParseFailed(definition) {
return map[string]struct{}{}
}
if definition.Agent == nil || !frontmatterDeclaresField(definition, "mcpServers") {
return nil
}
allowlist := make(map[string]struct{}, len(definition.Agent.Frontmatter.MCPServers))
for _, raw := range definition.Agent.Frontmatter.MCPServers {
trimmed := strings.ToLower(strings.TrimSpace(raw))
if trimmed == "" {
continue
}
allowlist[trimmed] = struct{}{}
}
return allowlist
}
func frontmatterDeclaresField(definition AgentContextDefinition, field string) bool {
if definition.Agent == nil || definition.Agent.Frontmatter.Fields == nil {
return false
}
_, ok := definition.Agent.Frontmatter.Fields[field]
return ok
}
func frontmatterParseFailed(definition AgentContextDefinition) bool {
if definition.Agent == nil {
return false
}
if strings.TrimSpace(definition.Agent.RawFrontmatter) == "" {
return false
}
return strings.TrimSpace(definition.Agent.FrontmatterErr) != ""
}

View file

@ -0,0 +1,184 @@
package agent
import (
"context"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
agenttools "github.com/sipeed/picoclaw/pkg/tools"
)
type allowlistTestTool struct {
name string
}
func (t *allowlistTestTool) Name() string { return t.name }
func (t *allowlistTestTool) Description() string { return "test tool" }
func (t *allowlistTestTool) Parameters() map[string]any {
return map[string]any{"type": "object"}
}
func (t *allowlistTestTool) Execute(
_ context.Context,
_ map[string]any,
) *agenttools.ToolResult {
return agenttools.NewToolResult("ok")
}
func TestUnknownAgentToolNames(t *testing.T) {
workspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
tools: [read_file, web_serach, mcp_github_search]
---
# Agent
`,
})
defer cleanupWorkspace(t, workspace)
registry := agenttools.NewToolRegistry()
registry.Register(&allowlistTestTool{name: "read_file"})
registry.Register(&allowlistTestTool{name: "web_search"})
unknown := unknownAgentToolNames(registry, loadAgentDefinition(workspace))
if len(unknown) != 1 || unknown[0] != "web_serach" {
t.Fatalf("unknownAgentToolNames() = %v, want [web_serach]", unknown)
}
}
func TestUnknownAgentToolNamesUsesRegisteredRuntimeTools(t *testing.T) {
workspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
tools: [serial, reaction, send_tts, load_image, delegate, made_up]
---
# Agent
`,
})
defer cleanupWorkspace(t, workspace)
registry := agenttools.NewToolRegistry()
for _, name := range []string{"serial", "reaction", "send_tts", "load_image", "delegate"} {
registry.Register(&allowlistTestTool{name: name})
}
unknown := unknownAgentToolNames(registry, loadAgentDefinition(workspace))
if len(unknown) != 1 || unknown[0] != "made_up" {
t.Fatalf("unknownAgentToolNames() = %v, want [made_up]", unknown)
}
}
func TestResolveAgentToolAllowlistDistinguishesMissingAndEmptyToolsField(t *testing.T) {
tests := []struct {
name string
agentMD string
wantNil bool
wantEmpty bool
}{
{
name: "missing tools field allows all tools",
agentMD: `---
name: pico
---
# Agent
`,
wantNil: true,
},
{
name: "explicit empty tools list blocks all tools",
agentMD: `---
tools: []
---
# Agent
`,
wantEmpty: true,
},
{
name: "blank tools field blocks all tools",
agentMD: `---
tools:
---
# Agent
`,
wantEmpty: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
workspace := setupWorkspace(t, map[string]string{
"AGENT.md": tt.agentMD,
})
defer cleanupWorkspace(t, workspace)
allowlist := resolveAgentToolAllowlist(loadAgentDefinition(workspace))
if tt.wantNil {
if allowlist != nil {
t.Fatalf("resolveAgentToolAllowlist() = %v, want nil", allowlist)
}
return
}
if allowlist == nil {
t.Fatal("resolveAgentToolAllowlist() = nil, want explicit empty allowlist")
}
if len(allowlist) != 0 {
t.Fatalf("resolveAgentToolAllowlist() = %v, want empty allowlist", allowlist)
}
})
}
}
func TestUnknownAgentMCPServerNames(t *testing.T) {
workspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
mcpServers: [github, githb]
---
# Agent
`,
})
defer cleanupWorkspace(t, workspace)
cfg := &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
Servers: map[string]config.MCPServerConfig{
"github": {Enabled: true},
},
},
},
}
unknown := unknownAgentMCPServerNames(cfg, loadAgentDefinition(workspace))
if len(unknown) != 1 || unknown[0] != "githb" {
t.Fatalf("unknownAgentMCPServerNames() = %v, want [githb]", unknown)
}
}
func TestUnknownAgentMCPServerNamesMatchesConfigCaseInsensitively(t *testing.T) {
workspace := setupWorkspace(t, map[string]string{
"AGENT.md": `---
mcpServers: [github, FileSystem, slak]
---
# Agent
`,
})
defer cleanupWorkspace(t, workspace)
cfg := &config.Config{
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
Servers: map[string]config.MCPServerConfig{
"GitHub": {Enabled: true},
"filesystem": {Enabled: true},
},
},
},
}
unknown := unknownAgentMCPServerNames(cfg, loadAgentDefinition(workspace))
if len(unknown) != 1 || unknown[0] != "slak" {
t.Fatalf("unknownAgentMCPServerNames() = %v, want [slak]", unknown)
}
}

View file

@ -26,20 +26,48 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
al.registerActiveTurn(ts)
defer al.clearActiveTurn(ts)
if al.takePendingStop(ts.sessionKey) {
_ = ts.requestHardAbort()
}
turnStatus := TurnEndStatusCompleted
defer func() {
attemptedSkills := ts.attemptedSkillsSnapshot()
skillContextSnapshots := ts.skillContextSnapshotsSnapshot()
finalSuccessfulPath := []string(nil)
if turnStatus == TurnEndStatusCompleted {
if latest := ts.latestSkillContextSnapshot(); len(latest) > 0 {
finalSuccessfulPath = latest
} else {
finalSuccessfulPath = append([]string(nil), attemptedSkills...)
}
}
al.emitEvent(
runtimeevents.KindAgentTurnEnd,
ts.eventMeta("runTurn", "turn.end"),
TurnEndPayload{
Status: turnStatus,
Iterations: ts.currentIteration(),
Duration: time.Since(ts.startedAt),
FinalContentLen: ts.finalContentLen(),
Status: turnStatus,
Workspace: ts.workspace,
Iterations: ts.currentIteration(),
Duration: time.Since(ts.startedAt),
FinalContentLen: ts.finalContentLen(),
UserMessage: ts.userMessage,
FinalContent: ts.finalContentSnapshot(),
ActiveSkills: append([]string(nil), ts.activeSkills...),
AttemptedSkills: attemptedSkills,
FinalSuccessfulPath: finalSuccessfulPath,
SkillContextSnapshots: skillContextSnapshots,
ToolKinds: ts.toolKindsSnapshot(),
ToolExecutions: ts.toolExecutionsSnapshot(),
},
)
}()
if ts.hardAbortRequested() {
turnStatus = TurnEndStatusAborted
return al.abortTurn(ts)
}
al.emitEvent(
runtimeevents.KindAgentTurnStart,
ts.eventMeta("runTurn", "turn.start"),
@ -191,7 +219,11 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
if finalContent == "" {
finalContent = ts.opts.DefaultResponse
}
return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent)
result, finalizeErr := pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent)
if finalizeErr != nil {
turnStatus = TurnEndStatusError
}
return result, finalizeErr
case ControlToolLoop:
// Execute tools via Pipeline
toolCtrl := pipeline.ExecuteTools(ctx, turnCtx, ts, exec, iteration)
@ -218,7 +250,11 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
if exec.allResponsesHandled {
finalContent = ""
}
return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent)
result, finalizeErr := pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent)
if finalizeErr != nil {
turnStatus = TurnEndStatusError
}
return result, finalizeErr
}
}
}
@ -242,7 +278,11 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
return al.abortTurn(ts)
}
return pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent)
result, err := pipeline.Finalize(ctx, turnCtx, ts, exec, turnStatus, finalContent)
if err != nil {
turnStatus = TurnEndStatusError
}
return result, err
}
func (al *AgentLoop) abortTurn(ts *turnState) (turnResult, error) {

View file

@ -10,6 +10,7 @@ import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session"
)
// =============================================================================
@ -198,6 +199,15 @@ func makeTestProcessOpts(sessionKey string) processOptions {
}
}
type saveFailingSessionStore struct {
session.SessionStore
err error
}
func (s *saveFailingSessionStore) Save(_ string) error {
return s.err
}
// =============================================================================
// Pipeline Method Tests: SetupTurn
// =============================================================================
@ -261,6 +271,44 @@ func TestPipeline_CallLLM_SimpleResponse(t *testing.T) {
}
}
func TestRunTurn_FinalizeSaveErrorEmitsErrorTurnEnd(t *testing.T) {
al, agent, cleanup := newTurnCoordTestLoop(t, &simpleConvProvider{})
defer cleanup()
saveErr := errors.New("session save failed")
agent.Sessions = &saveFailingSessionStore{
SessionStore: session.NewSessionManager(""),
err: saveErr,
}
sub := al.SubscribeEvents(8)
defer al.UnsubscribeEvents(sub.ID)
if _, err := al.ProcessDirect(context.Background(), "hello", "session-save-fail"); err == nil {
t.Fatal("expected ProcessDirect to fail")
}
deadline := time.After(2 * time.Second)
for {
select {
case evt := <-sub.C:
if evt.Kind != EventKindTurnEnd {
continue
}
payload, ok := evt.Payload.(TurnEndPayload)
if !ok {
t.Fatalf("TurnEnd payload type = %T", evt.Payload)
}
if payload.Status != TurnEndStatusError {
t.Fatalf("TurnEnd status = %q, want %q", payload.Status, TurnEndStatusError)
}
return
case <-deadline:
t.Fatal("timed out waiting for turn_end event")
}
}
}
func TestPipeline_CallLLM_WithToolCall(t *testing.T) {
provider := &toolCallRespProvider{
toolName: "web_search",
@ -780,3 +828,30 @@ func TestTurnState_HardAbortRequested(t *testing.T) {
t.Error("expected hard abort to be requested")
}
}
func TestTurnState_SkillContextSnapshotsTrackLatestSuccessfulPath(t *testing.T) {
ts := &turnState{}
ts.recordSkillContextSnapshot(skillContextTriggerInitialBuild, []string{"skill-a"})
ts.recordSkillContextSnapshot(skillContextTriggerContextRetryRebuild, []string{"skill-b", "skill-c"})
if got := ts.attemptedSkillsSnapshot(); len(got) != 3 || got[0] != "skill-a" || got[1] != "skill-b" ||
got[2] != "skill-c" {
t.Fatalf("attemptedSkillsSnapshot = %v, want [skill-a skill-b skill-c]", got)
}
if got := ts.latestSkillContextSnapshot(); len(got) != 2 || got[0] != "skill-b" || got[1] != "skill-c" {
t.Fatalf("latestSkillContextSnapshot = %v, want [skill-b skill-c]", got)
}
snapshots := ts.skillContextSnapshotsSnapshot()
if len(snapshots) != 2 {
t.Fatalf("len(skillContextSnapshotsSnapshot()) = %d, want 2", len(snapshots))
}
if snapshots[0].Sequence != 1 || snapshots[0].Trigger != skillContextTriggerInitialBuild {
t.Fatalf("snapshots[0] = %+v, want sequence=1 trigger=%q", snapshots[0], skillContextTriggerInitialBuild)
}
if snapshots[1].Sequence != 2 || snapshots[1].Trigger != skillContextTriggerContextRetryRebuild {
t.Fatalf("snapshots[1] = %+v, want sequence=2 trigger=%q", snapshots[1], skillContextTriggerContextRetryRebuild)
}
}

View file

@ -5,6 +5,7 @@ package agent
import (
"context"
"reflect"
"strings"
"sync"
"sync/atomic"
"time"
@ -176,13 +177,19 @@ type turnState struct {
opts processOptions
scope turnEventScope
turnID string
agentID string
sessionKey string
turnCtx *TurnContext
turnID string
agentID string
sessionKey string
activeSkills []string
attemptedSkills []string
skillContextTrace []SkillContextSnapshot
toolKinds []string
toolExecutions []ToolExecutionRecord
turnCtx *TurnContext
channel string
chatID string
workspace string
userMessage string
media []string
@ -238,25 +245,30 @@ type turnState struct {
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.Dispatch.SessionKey,
turnCtx: cloneTurnContext(scope.context),
channel: opts.Dispatch.Channel(),
chatID: opts.Dispatch.ChatID(),
userMessage: opts.Dispatch.UserMessage,
media: append([]string(nil), opts.Dispatch.Media...),
phase: TurnPhaseSetup,
startedAt: time.Now(),
agent: agent,
opts: opts,
scope: scope,
turnID: scope.turnID,
agentID: agent.ID,
sessionKey: opts.Dispatch.SessionKey,
activeSkills: activeSkillNames(agent, opts),
turnCtx: cloneTurnContext(scope.context),
channel: opts.Dispatch.Channel(),
chatID: opts.Dispatch.ChatID(),
workspace: agent.Workspace,
userMessage: opts.Dispatch.UserMessage,
media: append([]string(nil), opts.Dispatch.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.Dispatch.SessionKey))
history := agent.Sessions.GetHistory(opts.Dispatch.SessionKey)
ts.initialHistoryLength = len(history)
ts.restorePointHistory = append([]providers.Message(nil), history...)
ts.restorePointSummary = agent.Sessions.GetSummary(opts.Dispatch.SessionKey)
}
return ts
@ -375,6 +387,160 @@ func (ts *turnState) finalContentLen() int {
return len(ts.finalContent)
}
func (ts *turnState) finalContentSnapshot() string {
ts.mu.RLock()
defer ts.mu.RUnlock()
return ts.finalContent
}
func (ts *turnState) recordToolKind(tool string) {
tool = strings.TrimSpace(tool)
if tool == "" {
return
}
ts.mu.Lock()
defer ts.mu.Unlock()
for _, existing := range ts.toolKinds {
if existing == tool {
return
}
}
ts.toolKinds = append(ts.toolKinds, tool)
}
func (ts *turnState) toolKindsSnapshot() []string {
ts.mu.RLock()
defer ts.mu.RUnlock()
return append([]string(nil), ts.toolKinds...)
}
func (ts *turnState) recordToolExecution(tool string, success bool, errorSummary string, skillNames []string) {
tool = strings.TrimSpace(tool)
if tool == "" {
return
}
ts.recordToolKind(tool)
ts.mu.Lock()
defer ts.mu.Unlock()
ts.toolExecutions = append(ts.toolExecutions, ToolExecutionRecord{
Name: tool,
Success: success,
ErrorSummary: strings.TrimSpace(errorSummary),
SkillNames: append([]string(nil), skillNames...),
})
}
func (ts *turnState) toolExecutionsSnapshot() []ToolExecutionRecord {
ts.mu.RLock()
defer ts.mu.RUnlock()
if len(ts.toolExecutions) == 0 {
return nil
}
out := make([]ToolExecutionRecord, 0, len(ts.toolExecutions))
for _, exec := range ts.toolExecutions {
out = append(out, ToolExecutionRecord{
Name: exec.Name,
Success: exec.Success,
ErrorSummary: exec.ErrorSummary,
SkillNames: append([]string(nil), exec.SkillNames...),
})
}
return out
}
func (ts *turnState) recordAttemptedSkills(skillNames []string) {
if len(skillNames) == 0 {
return
}
ts.mu.Lock()
defer ts.mu.Unlock()
for _, skillName := range skillNames {
skillName = strings.TrimSpace(skillName)
if skillName == "" {
continue
}
seen := false
for _, existing := range ts.attemptedSkills {
if existing == skillName {
seen = true
break
}
}
if seen {
continue
}
ts.attemptedSkills = append(ts.attemptedSkills, skillName)
}
}
func (ts *turnState) attemptedSkillsSnapshot() []string {
ts.mu.RLock()
defer ts.mu.RUnlock()
return append([]string(nil), ts.attemptedSkills...)
}
func (ts *turnState) recordSkillContextSnapshot(trigger string, skillNames []string) {
if len(skillNames) == 0 {
return
}
filtered := make([]string, 0, len(skillNames))
for _, skillName := range skillNames {
skillName = strings.TrimSpace(skillName)
if skillName == "" {
continue
}
filtered = append(filtered, skillName)
}
if len(filtered) == 0 {
return
}
ts.recordAttemptedSkills(filtered)
ts.mu.Lock()
defer ts.mu.Unlock()
ts.skillContextTrace = append(ts.skillContextTrace, SkillContextSnapshot{
Sequence: len(ts.skillContextTrace) + 1,
Trigger: trigger,
SkillNames: append([]string(nil), filtered...),
})
}
func (ts *turnState) latestSkillContextSnapshot() []string {
ts.mu.RLock()
defer ts.mu.RUnlock()
if len(ts.skillContextTrace) == 0 {
return nil
}
return append([]string(nil), ts.skillContextTrace[len(ts.skillContextTrace)-1].SkillNames...)
}
func (ts *turnState) skillContextSnapshotsSnapshot() []SkillContextSnapshot {
ts.mu.RLock()
defer ts.mu.RUnlock()
if len(ts.skillContextTrace) == 0 {
return nil
}
snapshots := make([]SkillContextSnapshot, 0, len(ts.skillContextTrace))
for _, snapshot := range ts.skillContextTrace {
snapshots = append(snapshots, SkillContextSnapshot{
Sequence: snapshot.Sequence,
Trigger: snapshot.Trigger,
SkillNames: append([]string(nil), snapshot.SkillNames...),
})
}
return snapshots
}
func (ts *turnState) setTurnCancel(cancel context.CancelFunc) {
ts.mu.Lock()
defer ts.mu.Unlock()

View file

@ -82,7 +82,8 @@ Notes:
"model_list": [
{
"model_name": "elevenlabs-asr",
"model": "elevenlabs/scribe_v1"
"provider": "elevenlabs",
"model": "scribe_v1"
}
]
}
@ -130,7 +131,7 @@ PicoClaw currently supports three main ASR routes:
| Route | Example models | Behavior |
| --- | --- | --- |
| ElevenLabs ASR | `elevenlabs/scribe_v1` | Uses the ElevenLabs transcription API. |
| ElevenLabs ASR | `provider: elevenlabs`, `model: scribe_v1` | Uses the ElevenLabs transcription API. |
| Whisper endpoint models | `openai/whisper-1`, `groq/whisper-large-v3` | Uses an OpenAI-compatible `/audio/transcriptions` endpoint. |
| Audio-capable chat models **(Under construction)** | `openai/gpt-4o-audio-preview`, `gemini/gemini-2.5-flash` | Sends audio to a multimodal chat model and asks it to transcribe. |
@ -142,7 +143,7 @@ If you are unsure which one to pick, choose Groq Whisper or ElevenLabs first.
1. **Preferred path**: resolve `voice.model_name` against `model_list`.
2. If that resolved model is:
- `elevenlabs/...`, PicoClaw uses the ElevenLabs transcriber.
- an `elevenlabs` provider model, PicoClaw uses the ElevenLabs transcriber.
- an OpenAI-compatible Whisper model, PicoClaw uses the Whisper transcriber.
- an audio-capable chat model, PicoClaw uses `AudioModelTranscriber`.
3. **Fallback path**: if `voice.model_name` is not set, PicoClaw performs a compatibility scan through `model_list` for legacy auto-detected ASR entries.

View file

@ -82,7 +82,8 @@ model_list:
"model_list": [
{
"model_name": "elevenlabs-asr",
"model": "elevenlabs/scribe_v1"
"provider": "elevenlabs",
"model": "scribe_v1"
}
]
}
@ -130,7 +131,7 @@ PicoClaw 目前主要支持三种 ASR 路径:
| 路径 | 示例模型 | 行为说明 |
| --- | --- | --- |
| ElevenLabs ASR | `elevenlabs/scribe_v1` | 使用 ElevenLabs 的语音转录接口。 |
| ElevenLabs ASR | `provider: elevenlabs``model: scribe_v1` | 使用 ElevenLabs 的语音转录接口。 |
| Whisper 接口模型 | `openai/whisper-1``groq/whisper-large-v3` | 使用 OpenAI 兼容的 `/audio/transcriptions` 接口。 |
| 支持音频的聊天模型 **(重构中)** | `openai/gpt-4o-audio-preview``gemini/gemini-2.5-flash` | 把音频发给多模态聊天模型,并要求它返回转录结果。 |
@ -142,7 +143,7 @@ PicoClaw 目前主要支持三种 ASR 路径:
1. **首选路径**:根据 `voice.model_name``model_list` 中找到对应模型。
2. 如果找到的模型属于以下类型:
- `elevenlabs/...`,则使用 ElevenLabs transcriber。
- `provider=elevenlabs` 的模型,则使用 ElevenLabs transcriber。
- OpenAI 兼容的 Whisper 模型,则使用 Whisper transcriber。
- 支持音频输入的聊天模型,则使用 `AudioModelTranscriber`
3. **回退路径**:如果没有设置 `voice.model_name`PicoClaw 会为了兼容旧配置,扫描 `model_list` 中可自动识别的 ASR 条目。

View file

@ -8,6 +8,12 @@ import (
"github.com/sipeed/picoclaw/pkg/providers"
)
const elevenLabsSupportedModelID = "scribe_v1"
func ElevenLabsSupportedModelID() string {
return elevenLabsSupportedModelID
}
type Transcriber interface {
Name() string
Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
@ -72,14 +78,23 @@ func whisperModelID(modelCfg *config.ModelConfig) string {
return ""
}
func isElevenLabsTranscriptionModel(modelCfg *config.ModelConfig) bool {
if modelCfg == nil || modelCfg.APIKey() == "" {
return false
}
protocol, _ := providers.ExtractProtocol(modelCfg)
return protocol == "elevenlabs"
}
func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber {
if modelCfg == nil {
return nil
}
protocol, _ := providers.ExtractProtocol(modelCfg)
if protocol == "elevenlabs" && modelCfg.APIKey() != "" {
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase)
if isElevenLabsTranscriptionModel(modelCfg) {
_, modelID := providers.ExtractProtocol(modelCfg)
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID)
}
if modelID := whisperModelID(modelCfg); modelID != "" {
return NewWhisperTranscriber(modelCfg)
@ -95,9 +110,9 @@ func fallbackTranscriberFromModelConfig(modelCfg *config.ModelConfig) Transcribe
return nil
}
protocol, _ := providers.ExtractProtocol(modelCfg)
if protocol == "elevenlabs" && modelCfg.APIKey() != "" {
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase)
if isElevenLabsTranscriptionModel(modelCfg) {
_, modelID := providers.ExtractProtocol(modelCfg)
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID)
}
if modelID := whisperModelID(modelCfg); modelID != "" {
return NewWhisperTranscriber(modelCfg)

View file

@ -46,6 +46,21 @@ func TestDetectTranscriber(t *testing.T) {
},
wantName: "elevenlabs",
},
{
name: "explicit elevenlabs provider selects elevenlabs transcriber",
cfg: &config.Config{
Voice: config.VoiceConfig{ModelName: "my-asr-model"},
ModelList: []*config.ModelConfig{
{
ModelName: "my-asr-model",
Provider: "elevenlabs",
Model: "scribe_v1",
APIKeys: config.SimpleSecureStrings("sk_elevenlabs_test"),
},
},
},
wantName: "elevenlabs",
},
{
name: "voice model name alias selects whisper transcriber for groq",
cfg: &config.Config{

View file

@ -20,19 +20,24 @@ import (
type ElevenLabsTranscriber struct {
apiKey string
apiBase string
modelID string
httpClient *http.Client
}
func NewElevenLabsTranscriber(apiKey, apiBase string) *ElevenLabsTranscriber {
func NewElevenLabsTranscriber(apiKey, apiBase, modelID string) *ElevenLabsTranscriber {
logger.DebugCF("voice", "Creating ElevenLabs transcriber", map[string]any{"has_api_key": apiKey != ""})
if apiBase == "" {
apiBase = "https://api.elevenlabs.io"
}
if modelID == "" || modelID != ElevenLabsSupportedModelID() {
modelID = ElevenLabsSupportedModelID()
}
return &ElevenLabsTranscriber{
apiKey: apiKey,
apiBase: apiBase,
modelID: modelID,
httpClient: &http.Client{
Timeout: 120 * time.Second,
},
@ -74,7 +79,7 @@ func (t *ElevenLabsTranscriber) Transcribe(ctx context.Context, audioFilePath st
return nil, fmt.Errorf("failed to copy file content: %w", err)
}
if err = writer.WriteField("model_id", "scribe_v1"); err != nil {
if err = writer.WriteField("model_id", t.modelID); err != nil {
return nil, fmt.Errorf("failed to write model_id field: %w", err)
}

View file

@ -3,10 +3,14 @@ package asr
import (
"context"
"encoding/json"
"io"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
@ -14,7 +18,7 @@ import (
var _ Transcriber = (*ElevenLabsTranscriber)(nil)
func TestElevenLabsTranscriberName(t *testing.T) {
tr := NewElevenLabsTranscriber("sk_test", "")
tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1")
if got := tr.Name(); got != "elevenlabs" {
t.Errorf("Name() = %q, want %q", got, "elevenlabs")
}
@ -35,6 +39,35 @@ func TestElevenLabsTranscribe(t *testing.T) {
if r.Header.Get("Xi-Api-Key") != "sk_test" {
t.Errorf("unexpected xi-api-key header: %s", r.Header.Get("Xi-Api-Key"))
}
mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
t.Fatalf("ParseMediaType() error = %v", err)
}
if mediaType != "multipart/form-data" {
t.Fatalf("content-type = %q, want multipart/form-data", mediaType)
}
reader := multipart.NewReader(r.Body, params["boundary"])
var gotModelID string
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("NextPart() error = %v", err)
}
if part.FormName() != "model_id" {
continue
}
body, err := io.ReadAll(part)
if err != nil {
t.Fatalf("ReadAll(part) error = %v", err)
}
gotModelID = strings.TrimSpace(string(body))
}
if gotModelID != "scribe_v1" {
t.Fatalf("model_id = %q, want %q", gotModelID, "scribe_v1")
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(TranscriptionResponse{
Text: "hello from elevenlabs",
@ -43,7 +76,7 @@ func TestElevenLabsTranscribe(t *testing.T) {
}))
defer srv.Close()
tr := NewElevenLabsTranscriber("sk_test", "")
tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1")
tr.apiBase = srv.URL
resp, err := tr.Transcribe(context.Background(), audioPath)
@ -64,7 +97,7 @@ func TestElevenLabsTranscribe(t *testing.T) {
}))
defer srv.Close()
tr := NewElevenLabsTranscriber("sk_bad", "")
tr := NewElevenLabsTranscriber("sk_bad", "", "scribe_v1")
tr.apiBase = srv.URL
_, err := tr.Transcribe(context.Background(), audioPath)
@ -74,10 +107,54 @@ func TestElevenLabsTranscribe(t *testing.T) {
})
t.Run("missing file", func(t *testing.T) {
tr := NewElevenLabsTranscriber("sk_test", "")
tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1")
_, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
if err == nil {
t.Fatal("expected error for missing file, got nil")
}
})
t.Run("unsupported model falls back to scribe_v1", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mediaType, params, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil {
t.Fatalf("ParseMediaType() error = %v", err)
}
if mediaType != "multipart/form-data" {
t.Fatalf("content-type = %q, want multipart/form-data", mediaType)
}
reader := multipart.NewReader(r.Body, params["boundary"])
var gotModelID string
for {
part, err := reader.NextPart()
if err == io.EOF {
break
}
if err != nil {
t.Fatalf("NextPart() error = %v", err)
}
if part.FormName() != "model_id" {
continue
}
body, err := io.ReadAll(part)
if err != nil {
t.Fatalf("ReadAll(part) error = %v", err)
}
gotModelID = strings.TrimSpace(string(body))
}
if gotModelID != "scribe_v1" {
t.Fatalf("model_id = %q, want runtime fallback to %q", gotModelID, "scribe_v1")
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(TranscriptionResponse{Text: "ok"})
}))
defer srv.Close()
tr := NewElevenLabsTranscriber("sk_test", "", "unsupported-model")
tr.apiBase = srv.URL
if _, err := tr.Transcribe(context.Background(), audioPath); err != nil {
t.Fatalf("Transcribe() error: %v", err)
}
})
}

View file

@ -1310,6 +1310,7 @@ make test # Full test suite
| `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge mode) |
| `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (Native whatsmeow mode) |
| `pkg/channels/maixcam/` | `"maixcam"` | — |
| `pkg/channels/mqtt/` | `"mqtt"` | — |
| `pkg/channels/pico/` | `"pico"` | TypingCapable, PlaceholderCapable, MessageEditor, WebhookHandler |
### A.3 Interface Quick Reference

View file

@ -1308,6 +1308,7 @@ make test # 全量测试
| `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge 模式) |
| `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (原生 whatsmeow 模式) |
| `pkg/channels/maixcam/` | `"maixcam"` | — |
| `pkg/channels/mqtt/` | `"mqtt"` | — |
| `pkg/channels/pico/` | `"pico"` | TypingCapable, PlaceholderCapable, MessageEditor, WebhookHandler |
### A.3 接口速查表

View file

@ -1,19 +1,17 @@
package line
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/line/line-bot-sdk-go/v8/linebot/messaging_api"
"github.com/line/line-bot-sdk-go/v8/linebot/webhook"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
@ -24,13 +22,7 @@ import (
)
const (
lineAPIBase = "https://api.line.me/v2/bot"
lineDataAPIBase = "https://api-data.line.me/v2/bot"
lineReplyEndpoint = lineAPIBase + "/message/reply"
linePushEndpoint = lineAPIBase + "/message/push"
lineContentEndpoint = lineDataAPIBase + "/message/%s/content"
lineBotInfoEndpoint = lineAPIBase + "/info"
lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
lineContentEndpoint = "https://api-data.line.me/v2/bot/message/%s/content"
lineReplyTokenMaxAge = 25 * time.Second
// Limit request body to prevent memory exhaustion (DoS).
@ -45,17 +37,16 @@ type replyTokenEntry struct {
// LINEChannel implements the Channel interface for LINE Official Account
// using the LINE Messaging API with HTTP webhook for receiving messages
// and REST API for sending messages.
// and the official LINE Bot SDK for sending messages.
type LINEChannel struct {
*channels.BaseChannel
config *config.LINESettings
infoClient *http.Client // for bot info lookups (short timeout)
apiClient *http.Client // for messaging API calls
botUserID string // Bot's user ID
botBasicID string // Bot's basic ID (e.g. @216ru...)
botDisplayName string // Bot's display name for text-based mention detection
replyTokens sync.Map // chatID -> replyTokenEntry
quoteTokens sync.Map // chatID -> quoteToken (string)
client *messaging_api.MessagingApiAPI
botUserID string // Bot's user ID
botBasicID string // Bot's basic ID (e.g. @216ru...)
botDisplayName string // Bot's display name for text-based mention detection
replyTokens sync.Map // chatID -> replyTokenEntry
quoteTokens sync.Map // chatID -> quoteToken (string)
ctx context.Context
cancel context.CancelFunc
}
@ -70,6 +61,14 @@ func NewLINEChannel(
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
}
client, err := messaging_api.NewMessagingApiAPI(
cfg.ChannelAccessToken.String(),
messaging_api.WithHTTPClient(&http.Client{Timeout: 30 * time.Second}),
)
if err != nil {
return nil, fmt.Errorf("failed to create LINE messaging client: %w", err)
}
base := channels.NewBaseChannel("line", cfg, messageBus, bc.AllowFrom,
channels.WithMaxMessageLength(5000),
channels.WithGroupTrigger(bc.GroupTrigger),
@ -79,8 +78,7 @@ func NewLINEChannel(
return &LINEChannel{
BaseChannel: base,
config: cfg,
infoClient: &http.Client{Timeout: 10 * time.Second},
apiClient: &http.Client{Timeout: 30 * time.Second},
client: client,
}, nil
}
@ -91,11 +89,15 @@ func (c *LINEChannel) Start(ctx context.Context) error {
c.ctx, c.cancel = context.WithCancel(ctx)
// Fetch bot profile to get bot's userId for mention detection
if err := c.fetchBotInfo(); err != nil {
info, err := c.client.WithContext(ctx).GetBotInfo()
if err != nil {
logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{
"error": err.Error(),
})
} else {
c.botUserID = info.UserId
c.botBasicID = info.BasicId
c.botDisplayName = info.DisplayName
logger.InfoCF("line", "Bot info fetched", map[string]any{
"bot_user_id": c.botUserID,
"basic_id": c.botBasicID,
@ -108,39 +110,6 @@ func (c *LINEChannel) Start(ctx context.Context) error {
return nil
}
// fetchBotInfo retrieves the bot's userId, basicId, and displayName from the LINE API.
func (c *LINEChannel) fetchBotInfo() error {
req, err := http.NewRequest(http.MethodGet, lineBotInfoEndpoint, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken.String())
resp, err := c.infoClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bot info API returned status %d", resp.StatusCode)
}
var info struct {
UserID string `json:"userId"`
BasicID string `json:"basicId"`
DisplayName string `json:"displayName"`
}
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return err
}
c.botUserID = info.UserID
c.botBasicID = info.BasicID
c.botDisplayName = info.DisplayName
return nil
}
// Stop gracefully stops the LINE channel.
func (c *LINEChannel) Stop(ctx context.Context) error {
logger.InfoC("line", "Stopping LINE channel")
@ -174,140 +143,70 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
// Limit body size to prevent memory exhaustion (DoS).
// ParseRequest reads r.Body internally via io.ReadAll; wrapping with
// MaxBytesReader ensures oversized payloads are rejected before full
// allocation.
r.Body = http.MaxBytesReader(w, r.Body, maxWebhookBodySize)
cb, err := webhook.ParseRequest(c.config.ChannelSecret.String(), r)
if err != nil {
logger.ErrorCF("line", "Failed to read request body", map[string]any{
"error": err.Error(),
})
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
if int64(len(body)) > maxWebhookBodySize {
logger.WarnC("line", "Webhook request body too large, rejected")
http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge)
return
}
signature := r.Header.Get("X-Line-Signature")
if !c.verifySignature(body, signature) {
logger.WarnC("line", "Invalid webhook signature")
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
var payload struct {
Events []lineEvent `json:"events"`
}
if err := json.Unmarshal(body, &payload); err != nil {
logger.ErrorCF("line", "Failed to parse webhook payload", map[string]any{
"error": err.Error(),
})
http.Error(w, "Bad request", http.StatusBadRequest)
var maxBytesErr *http.MaxBytesError
if errors.As(err, &maxBytesErr) {
logger.WarnC("line", "Webhook request body too large, rejected")
http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge)
} else if errors.Is(err, webhook.ErrInvalidSignature) {
logger.WarnC("line", "Invalid webhook signature")
http.Error(w, "Forbidden", http.StatusForbidden)
} else {
logger.ErrorCF("line", "Failed to parse webhook request", map[string]any{
"error": err.Error(),
})
http.Error(w, "Bad request", http.StatusBadRequest)
}
return
}
// Return 200 immediately, process events asynchronously
w.WriteHeader(http.StatusOK)
for _, event := range payload.Events {
for _, event := range cb.Events {
go c.processEvent(event)
}
}
// verifySignature validates the X-Line-Signature using HMAC-SHA256.
func (c *LINEChannel) verifySignature(body []byte, signature string) bool {
if signature == "" {
return false
}
mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret.String()))
mac.Write(body)
expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}
// LINE webhook event types
type lineEvent struct {
Type string `json:"type"`
ReplyToken string `json:"replyToken"`
Source lineSource `json:"source"`
Message json.RawMessage `json:"message"`
Timestamp int64 `json:"timestamp"`
}
type lineSource struct {
Type string `json:"type"` // "user", "group", "room"
UserID string `json:"userId"`
GroupID string `json:"groupId"`
RoomID string `json:"roomId"`
}
type lineMessage struct {
ID string `json:"id"`
Type string `json:"type"` // "text", "image", "video", "audio", "file", "sticker"
Text string `json:"text"`
QuoteToken string `json:"quoteToken"`
Mention *struct {
Mentionees []lineMentionee `json:"mentionees"`
} `json:"mention"`
ContentProvider struct {
Type string `json:"type"`
} `json:"contentProvider"`
}
type lineMentionee struct {
Index int `json:"index"`
Length int `json:"length"`
Type string `json:"type"` // "user", "all"
UserID string `json:"userId"`
}
func (c *LINEChannel) processEvent(event lineEvent) {
if event.Type != "message" {
func (c *LINEChannel) processEvent(event webhook.EventInterface) {
msgEvent, ok := event.(webhook.MessageEvent)
if !ok {
logger.DebugCF("line", "Ignoring non-message event", map[string]any{
"type": event.Type,
"type": event.GetType(),
})
return
}
senderID := event.Source.UserID
chatID := c.resolveChatID(event.Source)
isGroup := event.Source.Type == "group" || event.Source.Type == "room"
var msg lineMessage
if err := json.Unmarshal(event.Message, &msg); err != nil {
logger.ErrorCF("line", "Failed to parse message", map[string]any{
"error": err.Error(),
})
return
}
senderID, chatID, sourceType := c.resolveSource(msgEvent.Source)
isGroup := sourceType == "group" || sourceType == "room"
// Store reply token for later use
if event.ReplyToken != "" {
if msgEvent.ReplyToken != "" {
c.replyTokens.Store(chatID, replyTokenEntry{
token: event.ReplyToken,
token: msgEvent.ReplyToken,
timestamp: time.Now(),
})
}
// Store quote token for quoting the original message in reply
if msg.QuoteToken != "" {
c.quoteTokens.Store(chatID, msg.QuoteToken)
}
var content string
var mediaPaths []string
scope := channels.BuildMediaScope("line", chatID, msg.ID)
var messageID string
var quoteToken string
var isMentioned bool
// Helper to register a local file with the media store
storeMedia := func(localPath, filename string) string {
storeMedia := func(localPath, filename, scope string) string {
if store := c.GetMediaStore(); store != nil {
ref, err := store.Store(localPath, media.MediaMeta{
Filename: filename,
Source: "line",
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
Filename: filename,
Source: "line",
}, scope)
if err == nil {
return ref
@ -316,37 +215,70 @@ func (c *LINEChannel) processEvent(event lineEvent) {
return localPath // fallback
}
switch msg.Type {
case "text":
switch msg := msgEvent.Message.(type) {
case webhook.TextMessageContent:
messageID = msg.Id
content = msg.Text
isMentioned = c.isBotMentioned(msg)
// Store quote token for quoting the original message in reply
if msg.QuoteToken != "" {
quoteToken = msg.QuoteToken
c.quoteTokens.Store(chatID, msg.QuoteToken)
}
// Strip bot mention from text in group chats
if isGroup {
content = c.stripBotMention(content, msg)
}
case "image":
localPath := c.downloadContent(msg.ID, "image.jpg")
if localPath != "" {
mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg"))
case webhook.ImageMessageContent:
messageID = msg.Id
if msg.QuoteToken != "" {
quoteToken = msg.QuoteToken
c.quoteTokens.Store(chatID, msg.QuoteToken)
}
if localPath := c.downloadContent(msg.Id, "image.jpg"); localPath != "" {
scope := channels.BuildMediaScope("line", chatID, msg.Id)
mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg", scope))
content = "[image]"
}
case "audio":
localPath := c.downloadContent(msg.ID, "audio.m4a")
if localPath != "" {
mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a"))
case webhook.AudioMessageContent:
messageID = msg.Id
if localPath := c.downloadContent(msg.Id, "audio.m4a"); localPath != "" {
scope := channels.BuildMediaScope("line", chatID, msg.Id)
mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a", scope))
content = "[audio]"
}
case "video":
localPath := c.downloadContent(msg.ID, "video.mp4")
if localPath != "" {
mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4"))
case webhook.VideoMessageContent:
messageID = msg.Id
if msg.QuoteToken != "" {
quoteToken = msg.QuoteToken
c.quoteTokens.Store(chatID, msg.QuoteToken)
}
if localPath := c.downloadContent(msg.Id, "video.mp4"); localPath != "" {
scope := channels.BuildMediaScope("line", chatID, msg.Id)
mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4", scope))
content = "[video]"
}
case "file":
case webhook.FileMessageContent:
messageID = msg.Id
content = "[file]"
case "sticker":
case webhook.LocationMessageContent:
messageID = msg.Id
content = "[location]"
if msg.Title != "" {
content = fmt.Sprintf("[location: %s]", msg.Title)
}
case webhook.StickerMessageContent:
messageID = msg.Id
if msg.QuoteToken != "" {
quoteToken = msg.QuoteToken
c.quoteTokens.Store(chatID, msg.QuoteToken)
}
content = "[sticker]"
default:
content = fmt.Sprintf("[%s]", msg.Type)
logger.DebugCF("line", "Ignoring unsupported message type", map[string]any{
"type": msgEvent.Message.GetType(),
})
return
}
if strings.TrimSpace(content) == "" {
@ -354,9 +286,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
}
// In group chats, apply unified group trigger filtering
isMentioned := false
if isGroup {
isMentioned = c.isBotMentioned(msg)
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
if !respond {
logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{
@ -369,13 +299,13 @@ func (c *LINEChannel) processEvent(event lineEvent) {
metadata := map[string]string{
"platform": "line",
"source_type": event.Source.Type,
"source_type": sourceType,
}
logger.DebugCF("line", "Received message", map[string]any{
"sender_id": senderID,
"chat_id": chatID,
"message_type": msg.Type,
"message_type": msgEvent.Message.GetType(),
"is_group": isGroup,
"preview": utils.Truncate(content, 50),
})
@ -395,16 +325,16 @@ func (c *LINEChannel) processEvent(event lineEvent) {
ChatID: chatID,
ChatType: map[bool]string{true: "group", false: "direct"}[isGroup],
SenderID: senderID,
MessageID: msg.ID,
MessageID: messageID,
Mentioned: isMentioned,
Raw: metadata,
}
if event.ReplyToken != "" {
if msgEvent.ReplyToken != "" {
inboundCtx.ReplyHandles = map[string]string{
"reply_token": event.ReplyToken,
"reply_token": msgEvent.ReplyToken,
}
if msg.QuoteToken != "" {
inboundCtx.ReplyHandles["quote_token"] = msg.QuoteToken
if quoteToken != "" {
inboundCtx.ReplyHandles["quote_token"] = quoteToken
}
}
@ -412,30 +342,28 @@ func (c *LINEChannel) processEvent(event lineEvent) {
}
// isBotMentioned checks if the bot is mentioned in the message.
// It first checks the mention metadata (userId match), then falls back
// It first checks the mention metadata (userId match or IsSelf), then falls back
// to text-based detection using the bot's display name, since LINE may
// not include userId in mentionees for Official Accounts.
func (c *LINEChannel) isBotMentioned(msg lineMessage) bool {
// Check mention metadata
func (c *LINEChannel) isBotMentioned(msg webhook.TextMessageContent) bool {
if msg.Mention != nil {
for _, m := range msg.Mention.Mentionees {
if m.Type == "all" {
switch mentionee := m.(type) {
case webhook.AllMentionee:
return true
}
if c.botUserID != "" && m.UserID == c.botUserID {
return true
}
}
// Mention metadata exists with mentionees but bot not matched by userId.
// The bot IS likely mentioned (LINE includes mention struct when bot is @-ed),
// so check if any mentionee overlaps with bot display name in text.
if c.botDisplayName != "" {
for _, m := range msg.Mention.Mentionees {
if m.Index >= 0 && m.Length > 0 {
case webhook.UserMentionee:
if mentionee.IsSelf {
return true
}
if c.botUserID != "" && mentionee.UserId == c.botUserID {
return true
}
// Check if mentionee text overlaps with bot display name
if c.botDisplayName != "" && mentionee.Index >= 0 && mentionee.Length > 0 {
runes := []rune(msg.Text)
end := m.Index + m.Length
end := int(mentionee.Index) + int(mentionee.Length)
if end <= len(runes) {
mentionText := string(runes[m.Index:end])
mentionText := string(runes[mentionee.Index:end])
if strings.Contains(mentionText, c.botDisplayName) {
return true
}
@ -454,30 +382,43 @@ func (c *LINEChannel) isBotMentioned(msg lineMessage) bool {
}
// stripBotMention removes the @BotName mention text from the message.
func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string {
func (c *LINEChannel) stripBotMention(text string, msg webhook.TextMessageContent) string {
stripped := false
// Try to strip using mention metadata indices
if msg.Mention != nil {
runes := []rune(text)
for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- {
m := msg.Mention.Mentionees[i]
// Strip if userId matches OR if the mention text contains the bot display name
shouldStrip := false
if c.botUserID != "" && m.UserID == c.botUserID {
shouldStrip = true
} else if c.botDisplayName != "" && m.Index >= 0 && m.Length > 0 {
end := m.Index + m.Length
if end <= len(runes) {
mentionText := string(runes[m.Index:end])
if strings.Contains(mentionText, c.botDisplayName) {
shouldStrip = true
var index, length int32
switch mentionee := m.(type) {
case webhook.UserMentionee:
index = mentionee.Index
length = mentionee.Length
if mentionee.IsSelf {
shouldStrip = true
} else if c.botUserID != "" && mentionee.UserId == c.botUserID {
shouldStrip = true
} else if c.botDisplayName != "" && index >= 0 && length > 0 {
end := int(index) + int(length)
if end <= len(runes) {
mentionText := string(runes[index:end])
if strings.Contains(mentionText, c.botDisplayName) {
shouldStrip = true
}
}
}
case webhook.AllMentionee:
// Don't strip @All mentions
continue
default:
continue
}
if shouldStrip {
start := m.Index
end := m.Index + m.Length
start := int(index)
end := int(index) + int(length)
if start >= 0 && end <= len(runes) {
runes = append(runes[:start], runes[end:]...)
stripped = true
@ -497,16 +438,20 @@ func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string {
return strings.TrimSpace(text)
}
// resolveChatID determines the chat ID from the event source.
// For group/room messages, use the group/room ID; for 1:1, use the user ID.
func (c *LINEChannel) resolveChatID(source lineSource) string {
switch source.Type {
case "group":
return source.GroupID
case "room":
return source.RoomID
// resolveSource extracts senderID, chatID, and source type from the event source.
func (c *LINEChannel) resolveSource(source webhook.SourceInterface) (senderID, chatID, sourceType string) {
switch src := source.(type) {
case webhook.GroupSource:
return src.UserId, src.GroupId, "group"
case webhook.RoomSource:
return src.UserId, src.RoomId, "room"
case webhook.UserSource:
return src.UserId, src.UserId, "user"
default:
return source.UserID
logger.WarnCF("line", "Unknown source type", map[string]any{
"type": fmt.Sprintf("%T", source),
})
return "", "", "unknown"
}
}
@ -523,23 +468,41 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
quoteToken = qt.(string)
}
textMsg := messaging_api.TextMessage{
Text: msg.Content,
QuoteToken: quoteToken,
}
// Try reply token first (free, valid for ~25 seconds)
if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok {
tokenEntry := entry.(replyTokenEntry)
if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil {
resp, _, err := c.client.WithContext(ctx).ReplyMessageWithHttpInfo(&messaging_api.ReplyMessageRequest{
ReplyToken: tokenEntry.token,
Messages: []messaging_api.MessageInterface{&textMsg},
})
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
if err == nil {
logger.DebugCF("line", "Message sent via Reply API", map[string]any{
"chat_id": msg.ChatID,
"quoted": quoteToken != "",
})
return nil, nil
}
logger.DebugC("line", "Reply API failed, falling back to Push API")
logger.DebugCF("line", "Reply API failed, falling back to Push API", map[string]any{
"error": err.Error(),
})
}
}
// Fall back to Push API
return nil, c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken)
resp, _, err := c.client.WithContext(ctx).PushMessageWithHttpInfo(&messaging_api.PushMessageRequest{
To: msg.ChatID,
Messages: []messaging_api.MessageInterface{&textMsg},
}, "")
return nil, classifySDKError(resp, err)
}
// SendMedia implements the channels.MediaSender interface.
@ -564,46 +527,19 @@ func (c *LINEChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessag
caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename)
}
if err := c.sendPush(ctx, msg.ChatID, caption, ""); err != nil {
return nil, err
textMsg := messaging_api.TextMessage{Text: caption}
resp, _, err := c.client.WithContext(ctx).PushMessageWithHttpInfo(&messaging_api.PushMessageRequest{
To: msg.ChatID,
Messages: []messaging_api.MessageInterface{&textMsg},
}, "")
if sdkErr := classifySDKError(resp, err); sdkErr != nil {
return nil, sdkErr
}
}
return nil, nil
}
// buildTextMessage creates a text message object, optionally with quoteToken.
func buildTextMessage(content, quoteToken string) map[string]string {
msg := map[string]string{
"type": "text",
"text": content,
}
if quoteToken != "" {
msg["quoteToken"] = quoteToken
}
return msg
}
// sendReply sends a message using the LINE Reply API.
func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error {
payload := map[string]any{
"replyToken": replyToken,
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
}
return c.callAPI(ctx, lineReplyEndpoint, payload)
}
// sendPush sends a message using the LINE Push API.
func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error {
payload := map[string]any{
"to": to,
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
}
return c.callAPI(ctx, linePushEndpoint, payload)
}
// StartTyping implements channels.TypingCapable using LINE's loading animation.
//
// NOTE: The LINE loading animation API only works for 1:1 chats.
@ -649,48 +585,31 @@ func (c *LINEChannel) StartTyping(ctx context.Context, chatID string) (func(), e
return stop, nil
}
// classifySDKError maps an SDK HTTP response to the project's sentinel errors.
func classifySDKError(resp *http.Response, err error) error {
if resp != nil && resp.Body != nil {
resp.Body.Close()
}
if err == nil {
return nil
}
if resp != nil {
return channels.ClassifySendError(resp.StatusCode, err)
}
return channels.ClassifyNetError(err)
}
// sendLoading sends a loading animation indicator to the chat.
func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error {
payload := map[string]any{
"chatId": chatID,
"loadingSeconds": 60,
req := &messaging_api.ShowLoadingAnimationRequest{
ChatId: chatID,
LoadingSeconds: 60,
}
return c.callAPI(ctx, lineLoadingEndpoint, payload)
resp, _, err := c.client.WithContext(ctx).ShowLoadingAnimationWithHttpInfo(req)
return classifySDKError(resp, err)
}
// callAPI makes an authenticated POST request to the LINE API.
func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken.String())
resp, err := c.apiClient.Do(req)
if err != nil {
return channels.ClassifyNetError(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("reading LINE API error response: %w", err))
}
return channels.ClassifySendError(resp.StatusCode, fmt.Errorf("LINE API error: %s", string(respBody)))
}
return nil
}
// downloadContent downloads media content from the LINE API.
// downloadContent downloads media content from the LINE content API.
func (c *LINEChannel) downloadContent(messageID, filename string) string {
url := fmt.Sprintf(lineContentEndpoint, messageID)
return utils.DownloadFile(url, filename, utils.DownloadOptions{

View file

@ -11,7 +11,7 @@ import (
)
func TestWebhookRejectsOversizedBody(t *testing.T) {
ch := &LINEChannel{}
ch := &LINEChannel{config: &config.LINESettings{}}
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
@ -25,7 +25,7 @@ func TestWebhookRejectsOversizedBody(t *testing.T) {
}
func TestWebhookAcceptsMaxBodySize(t *testing.T) {
ch := &LINEChannel{}
ch := &LINEChannel{config: &config.LINESettings{}}
body := bytes.Repeat([]byte("A"), maxWebhookBodySize)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
@ -40,7 +40,7 @@ func TestWebhookAcceptsMaxBodySize(t *testing.T) {
}
func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) {
ch := &LINEChannel{}
ch := &LINEChannel{config: &config.LINESettings{}}
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
@ -55,7 +55,7 @@ func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) {
}
func TestWebhookRejectsNonPostMethod(t *testing.T) {
ch := &LINEChannel{}
ch := &LINEChannel{config: &config.LINESettings{}}
req := httptest.NewRequest(http.MethodGet, "/webhook", nil)
rec := httptest.NewRecorder()

View file

@ -100,6 +100,10 @@ type Manager struct {
channelHashes map[string]string // channel name → config hash
}
type mediaStoreSetter interface {
SetMediaStore(s media.MediaStore)
}
// ManagerOption configures a channel Manager.
type ManagerOption func(*Manager)
@ -485,6 +489,22 @@ func NewManager(
return m, nil
}
// SetMediaStore updates the store used by the manager and every channel that
// accepts media store injection. Gateway reload creates a fresh store, so
// keeping existing channels on the same store as the agent is required for
// inbound media refs to remain resolvable after reload.
func (m *Manager) SetMediaStore(store media.MediaStore) {
m.mu.Lock()
defer m.mu.Unlock()
m.mediaStore = store
for _, ch := range m.channels {
if setter, ok := ch.(mediaStoreSetter); ok {
setter.SetMediaStore(store)
}
}
}
// GetStreamer implements bus.StreamDelegate.
// It checks if the named channel supports streaming and returns a Streamer.
func (m *Manager) GetStreamer(ctx context.Context, channelName, chatID string) (bus.Streamer, bool) {
@ -582,7 +602,7 @@ func (m *Manager) initChannel(typeName, channelName string) {
} else {
// Inject MediaStore if channel supports it
if m.mediaStore != nil {
if setter, ok := ch.(interface{ SetMediaStore(s media.MediaStore) }); ok {
if setter, ok := ch.(mediaStoreSetter); ok {
setter.SetMediaStore(m.mediaStore)
}
}
@ -668,10 +688,14 @@ func (m *Manager) getChannelConfigAndEnabled(channelName string) (*config.Channe
return bc, true
case *config.TeamsWebhookSettings:
return bc, true
case *config.SlackWebhookSettings:
return bc, true
case *config.DiscordSettings:
return bc, settings.Token.String() != ""
case *config.VKSettings:
return bc, settings.GroupID != 0 && settings.Token.String() != ""
case *config.MQTTSettings:
return bc, settings.Broker != "" && settings.AgentID != ""
}
return bc, bc.Enabled

View file

@ -102,6 +102,24 @@ func hiddenValues(key string, value map[string]any, ch *config.Channel) {
}
}
value["webhooks"] = webhooks
case "mqtt":
if settings, ok := v.(*config.MQTTSettings); ok {
value["username"] = settings.Username.String()
value["password"] = settings.Password.String()
}
case "slack_webhook":
// Expose webhook URLs for hash computation (they contain secrets)
if settings, ok := v.(*config.SlackWebhookSettings); ok {
webhooks := make(map[string]any)
for name, target := range settings.Webhooks {
webhooks[name] = map[string]any{
"webhook_url": target.WebhookURL.String(),
"username": target.Username,
"icon_emoji": target.IconEmoji,
}
}
value["webhooks"] = webhooks
}
}
}

View file

@ -15,6 +15,7 @@ import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
)
@ -149,6 +150,26 @@ func newTestManager() *Manager {
}
}
func TestSetMediaStorePropagatesToExistingChannels(t *testing.T) {
oldStore := media.NewFileMediaStore()
newStore := media.NewFileMediaStore()
ch := &mockChannel{}
ch.SetMediaStore(oldStore)
m := newTestManager()
m.mediaStore = oldStore
m.channels["telegram"] = ch
m.SetMediaStore(newStore)
if m.mediaStore != newStore {
t.Fatal("manager media store was not updated")
}
if got := ch.GetMediaStore(); got != newStore {
t.Fatalf("channel media store = %p, want %p", got, newStore)
}
}
func TestStartAll_AllChannelsFail_ReturnsJoinedError(t *testing.T) {
m := newTestManager()
errA := errors.New("channel-a start failed")

16
pkg/channels/mqtt/init.go Normal file
View file

@ -0,0 +1,16 @@
package mqtt
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterSafeFactory(
config.ChannelMQTT,
func(bc *config.Channel, cfg *config.MQTTSettings, b *bus.MessageBus) (channels.Channel, error) {
return NewMQTTChannel(bc, cfg, b)
},
)
}

255
pkg/channels/mqtt/mqtt.go Normal file
View file

@ -0,0 +1,255 @@
package mqtt
import (
"context"
"crypto/rand"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
pahomqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
// mqttPayload is the JSON payload for both inbound and outbound messages.
type mqttPayload struct {
Text string `json:"text"`
}
// MQTTChannel implements the Channel interface for MQTT-based communication.
type MQTTChannel struct {
*channels.BaseChannel
bc *config.Channel
cfg *config.MQTTSettings
client pahomqtt.Client
qos byte
clientID string
}
// NewMQTTChannel creates a new MQTT channel instance.
func NewMQTTChannel(bc *config.Channel, cfg *config.MQTTSettings, b *bus.MessageBus) (*MQTTChannel, error) {
if cfg.Broker == "" {
return nil, fmt.Errorf("mqtt broker is required")
}
if cfg.AgentID == "" {
return nil, fmt.Errorf("mqtt agent_id is required")
}
base := channels.NewBaseChannel("mqtt", cfg, b, bc.AllowFrom,
channels.WithGroupTrigger(bc.GroupTrigger),
channels.WithReasoningChannelID(bc.ReasoningChannelID),
)
mqttClientID := cfg.ClientID
if mqttClientID == "" {
var suffix [4]byte
_, _ = rand.Read(suffix[:])
mqttClientID = fmt.Sprintf("picoclaw-mqtt-%s-%s", cfg.AgentID, hex.EncodeToString(suffix[:]))
}
return &MQTTChannel{
BaseChannel: base,
bc: bc,
cfg: cfg,
qos: byte(cfg.QoS),
clientID: mqttClientID,
}, nil
}
// Start connects to the MQTT broker and begins listening for inbound messages.
func (c *MQTTChannel) Start(ctx context.Context) error {
logger.InfoC("mqtt", "Starting MQTT channel")
keepAlive := c.cfg.KeepAlive
if keepAlive <= 0 {
keepAlive = 60
}
opts := pahomqtt.NewClientOptions()
opts.AddBroker(c.cfg.Broker)
opts.SetClientID(c.clientID)
opts.SetKeepAlive(time.Duration(keepAlive) * time.Second)
opts.SetAutoReconnect(true)
opts.SetConnectRetry(true)
opts.SetConnectRetryInterval(5 * time.Second)
opts.SetTLSConfig(&tls.Config{InsecureSkipVerify: true}) //nolint:gosec
if c.cfg.Username.String() != "" {
opts.SetUsername(c.cfg.Username.String())
opts.SetPassword(c.cfg.Password.String())
}
firstSubscribe := make(chan error, 1)
var once sync.Once
opts.SetOnConnectHandler(func(client pahomqtt.Client) {
logger.InfoC("mqtt", "MQTT connected, subscribing to inbound topic")
err := c.subscribe(client)
once.Do(func() { firstSubscribe <- err })
})
opts.SetConnectionLostHandler(func(_ pahomqtt.Client, err error) {
logger.WarnCF("mqtt", "MQTT connection lost", map[string]any{"error": err.Error()})
})
client := pahomqtt.NewClient(opts)
token := client.Connect()
if !token.WaitTimeout(10 * time.Second) {
client.Disconnect(250)
return fmt.Errorf("mqtt connect timed out after 10s (broker: %s)", c.cfg.Broker)
}
if err := token.Error(); err != nil {
client.Disconnect(250)
return fmt.Errorf("mqtt connect failed: %w", err)
}
if err := <-firstSubscribe; err != nil {
client.Disconnect(250)
return fmt.Errorf("mqtt subscribe failed: %w", err)
}
c.client = client
c.SetRunning(true)
logger.InfoCF("mqtt", "MQTT channel started", map[string]any{
"broker": c.cfg.Broker,
"agent_id": c.cfg.AgentID,
})
return nil
}
// topicPrefix returns the configured topic prefix, normalizing slashes.
// Trailing slashes are stripped; the result may or may not have a leading slash
// depending on what the user configured.
func (c *MQTTChannel) topicPrefix() string {
p := strings.TrimRight(c.cfg.TopicPrefix, "/")
if p == "" {
return "/picoclaw"
}
return p
}
// clientIDFromTopic extracts the client_id segment from a received topic.
// Topic structure: {prefix}/{agent_id}/{client_id}/request
func (c *MQTTChannel) clientIDFromTopic(topic string) (string, bool) {
prefix := c.topicPrefix()
// Build the expected fixed portion: {prefix}/{agent_id}/
fixed := prefix + "/" + c.cfg.AgentID + "/"
after, ok := strings.CutPrefix(topic, fixed)
if !ok {
return "", false
}
// after = "{client_id}/request"
slash := strings.IndexByte(after, '/')
if slash < 0 {
return "", false
}
return after[:slash], true
}
// subscribe subscribes to the inbound topic for this agent.
func (c *MQTTChannel) subscribe(client pahomqtt.Client) error {
topic := fmt.Sprintf("%s/%s/+/request", c.topicPrefix(), c.cfg.AgentID)
token := client.Subscribe(topic, c.qos, func(_ pahomqtt.Client, msg pahomqtt.Message) {
c.handleInbound(msg)
})
token.Wait()
if err := token.Error(); err != nil {
logger.ErrorCF("mqtt", "Failed to subscribe", map[string]any{
"topic": topic,
"error": err.Error(),
})
return err
}
logger.InfoCF("mqtt", "Subscribed to inbound topic", map[string]any{"topic": topic})
return nil
}
// handleInbound processes an inbound MQTT message.
func (c *MQTTChannel) handleInbound(msg pahomqtt.Message) {
topic := msg.Topic()
clientID, ok := c.clientIDFromTopic(topic)
if !ok {
logger.WarnCF("mqtt", "Unexpected topic format", map[string]any{"topic": topic})
return
}
chatID := "mqtt:" + clientID
var payload mqttPayload
if err := json.Unmarshal(msg.Payload(), &payload); err != nil {
logger.WarnCF("mqtt", "Failed to parse inbound payload", map[string]any{
"topic": topic,
"error": err.Error(),
})
return
}
if payload.Text == "" {
logger.WarnCF("mqtt", "Inbound payload missing text", map[string]any{"topic": topic})
return
}
inboundCtx := bus.InboundContext{
Channel: "mqtt",
ChatID: chatID,
ChatType: "direct",
SenderID: clientID,
}
c.HandleInboundContext(context.Background(), chatID, payload.Text, nil, inboundCtx)
}
// Stop disconnects from the MQTT broker.
func (c *MQTTChannel) Stop(_ context.Context) error {
logger.InfoC("mqtt", "Stopping MQTT channel")
c.SetRunning(false)
if c.client != nil {
c.client.Disconnect(500)
}
logger.InfoC("mqtt", "MQTT channel stopped")
return nil
}
// Send publishes a response to the client via MQTT.
func (c *MQTTChannel) Send(_ context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
return nil, channels.ErrNotRunning
}
if strings.TrimSpace(msg.Content) == "" {
return nil, nil
}
clientID := strings.TrimPrefix(msg.ChatID, "mqtt:")
if clientID == msg.ChatID {
logger.WarnCF("mqtt", "Send called with unexpected chatID format", map[string]any{"chat_id": msg.ChatID})
return nil, nil
}
topic := fmt.Sprintf("%s/%s/%s/response", c.topicPrefix(), c.cfg.AgentID, clientID)
data, err := json.Marshal(mqttPayload{Text: msg.Content})
if err != nil {
return nil, fmt.Errorf("mqtt: failed to marshal outbound payload: %w", err)
}
token := c.client.Publish(topic, c.qos, false, data)
token.Wait()
if err := token.Error(); err != nil {
return nil, fmt.Errorf("mqtt: publish failed: %w", err)
}
logger.DebugCF("mqtt", "Published response", map[string]any{"topic": topic})
return nil, nil
}

View file

@ -0,0 +1,263 @@
package slackwebhook
import (
"fmt"
"regexp"
"strings"
)
const maxTableRowWidth = 60
var (
boldRe = regexp.MustCompile(`\*\*([^*]+)\*\*`)
strikeRe = regexp.MustCompile(`~~([^~]+)~~`)
linkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
headerRe = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`)
bulletRe = regexp.MustCompile(`(?m)^- (.+)$`)
markdownTableRe = regexp.MustCompile(`(?m)^(\|[^\n]+\|)\n(\|[-:\|\s]+\|)\n((?:\|[^\n]+\|\n?)+)`)
codeBlockRe = regexp.MustCompile("(?s)```.*?```")
inlineCodeRe = regexp.MustCompile("`[^`]+`")
italicRe = regexp.MustCompile(`(?:^|[^*])\*([^*]+)\*(?:[^*]|$)`)
)
type contentSegment struct {
content string
isTable bool
}
func convertMarkdownToMrkdwn(text string) string {
// Protect code blocks from conversion
var codeBlocks []string
text = codeBlockRe.ReplaceAllStringFunc(text, func(match string) string {
codeBlocks = append(codeBlocks, match)
return "\x00CODEBLOCK\x00"
})
// Protect inline code
var inlineCode []string
text = inlineCodeRe.ReplaceAllStringFunc(text, func(match string) string {
inlineCode = append(inlineCode, match)
return "\x00INLINE\x00"
})
// Convert italic *text* → _text_ BEFORE bold conversion
text = italicRe.ReplaceAllStringFunc(text, func(match string) string {
// Find the asterisk positions
firstAsterisk := strings.Index(match, "*")
lastAsterisk := strings.LastIndex(match, "*")
if firstAsterisk == lastAsterisk {
return match // Only one asterisk, not italic
}
// Extract content between asterisks
content := match[firstAsterisk+1 : lastAsterisk]
// Replace with underscores, preserving any prefix/suffix
return match[:firstAsterisk] + "_" + content + "_" + match[lastAsterisk+1:]
})
// Convert bold **text** → *text*
text = boldRe.ReplaceAllString(text, "*$1*")
// Convert strikethrough ~~text~~ → ~text~
text = strikeRe.ReplaceAllString(text, "~$1~")
// Convert links [text](url) → <url|text>
text = linkRe.ReplaceAllString(text, "<$2|$1>")
// Convert headers # text → *text*
text = headerRe.ReplaceAllString(text, "*$1*")
// Convert bullet lists - item → • item
text = bulletRe.ReplaceAllString(text, "• $1")
// Restore inline code
for _, code := range inlineCode {
text = strings.Replace(text, "\x00INLINE\x00", code, 1)
}
// Restore code blocks
for _, block := range codeBlocks {
text = strings.Replace(text, "\x00CODEBLOCK\x00", block, 1)
}
return text
}
func splitContentWithTables(content string) []contentSegment {
var segments []contentSegment
// Protect code blocks from table detection using unique placeholders
var codeBlocks []string
blockIdx := 0
protected := codeBlockRe.ReplaceAllStringFunc(content, func(match string) string {
codeBlocks = append(codeBlocks, match)
placeholder := fmt.Sprintf("\x00CODEBLOCK_%d\x00", blockIdx)
blockIdx++
return placeholder
})
matches := markdownTableRe.FindAllStringSubmatchIndex(protected, -1)
if len(matches) == 0 {
return []contentSegment{{content: content, isTable: false}}
}
// Restore code blocks using indexed placeholders
restoreCodeBlocks := func(s string) string {
result := s
for i, block := range codeBlocks {
placeholder := fmt.Sprintf("\x00CODEBLOCK_%d\x00", i)
result = strings.Replace(result, placeholder, block, 1)
}
return result
}
lastEnd := 0
for _, match := range matches {
if match[0] > lastEnd {
segments = append(segments, contentSegment{
content: restoreCodeBlocks(protected[lastEnd:match[0]]),
isTable: false,
})
}
segments = append(segments, contentSegment{
content: restoreCodeBlocks(protected[match[0]:match[1]]),
isTable: true,
})
lastEnd = match[1]
}
if lastEnd < len(protected) {
segments = append(segments, contentSegment{
content: restoreCodeBlocks(protected[lastEnd:]),
isTable: false,
})
}
return segments
}
func renderTable(tableStr string) string {
lines := strings.Split(strings.TrimSpace(tableStr), "\n")
if len(lines) < 2 {
return "```\n" + tableStr + "\n```"
}
// Parse all rows to get column widths
var allRows [][]string
maxCols := 0
for i, line := range lines {
if i == 1 && isSeparatorRow(line) {
continue
}
cells := parseTableRow(line)
if len(cells) > 0 {
allRows = append(allRows, cells)
if len(cells) > maxCols {
maxCols = len(cells)
}
}
}
if len(allRows) == 0 {
return "```\n" + tableStr + "\n```"
}
// Calculate max width for each column using rune count
colWidths := make([]int, maxCols)
for _, row := range allRows {
for i, cell := range row {
runeLen := len([]rune(cell))
if runeLen > colWidths[i] {
colWidths[i] = runeLen
}
}
}
// Check if table is narrow enough for mrkdwn format
totalWidth := 0
for _, w := range colWidths {
totalWidth += w
}
if len(colWidths) > 1 {
totalWidth += 3 * (len(colWidths) - 1) // " | " separators between columns
}
if totalWidth <= maxTableRowWidth {
// Render as formatted text with bold headers
var result strings.Builder
for i, row := range allRows {
if i == 0 {
var boldCells []string
for _, cell := range row {
boldCells = append(boldCells, "*"+cell+"*")
}
result.WriteString(strings.Join(boldCells, " | "))
} else {
result.WriteString(strings.Join(row, " | "))
}
result.WriteString("\n")
}
return strings.TrimSuffix(result.String(), "\n")
}
// Render as aligned code block
var result strings.Builder
result.WriteString("```\n")
for i, row := range allRows {
var paddedCells []string
for j, cell := range row {
if j < len(colWidths) {
paddedCells = append(paddedCells, padRight(cell, colWidths[j]))
} else {
paddedCells = append(paddedCells, cell)
}
}
result.WriteString("| ")
result.WriteString(strings.Join(paddedCells, " | "))
result.WriteString(" |\n")
// Add separator after header
if i == 0 {
var sepParts []string
for _, w := range colWidths {
sepParts = append(sepParts, strings.Repeat("-", w))
}
result.WriteString("|-")
result.WriteString(strings.Join(sepParts, "-|-"))
result.WriteString("-|\n")
}
}
result.WriteString("```")
return result.String()
}
func padRight(s string, width int) string {
runeLen := len([]rune(s))
if runeLen >= width {
return s
}
return s + strings.Repeat(" ", width-runeLen)
}
func isSeparatorRow(line string) bool {
cleaned := strings.ReplaceAll(line, "|", "")
cleaned = strings.ReplaceAll(cleaned, " ", "")
cleaned = strings.ReplaceAll(cleaned, "-", "")
cleaned = strings.ReplaceAll(cleaned, ":", "")
return cleaned == ""
}
func parseTableRow(line string) []string {
line = strings.TrimSpace(line)
line = strings.TrimPrefix(line, "|")
line = strings.TrimSuffix(line, "|")
if line == "" {
return nil
}
parts := strings.Split(line, "|")
var cells []string
for _, p := range parts {
cells = append(cells, strings.TrimSpace(p))
}
return cells
}

View file

@ -0,0 +1,187 @@
package slackwebhook
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestConvertMarkdownToMrkdwn(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "bold double asterisk",
input: "This is **bold** text",
expected: "This is *bold* text",
},
{
name: "italic single asterisk",
input: "This is *italic* text",
expected: "This is _italic_ text",
},
{
name: "italic underscore",
input: "This is _italic_ text",
expected: "This is _italic_ text",
},
{
name: "strikethrough",
input: "This is ~~struck~~ text",
expected: "This is ~struck~ text",
},
{
name: "inline code unchanged",
input: "Use `code` here",
expected: "Use `code` here",
},
{
name: "link conversion",
input: "Click [here](https://example.com) now",
expected: "Click <https://example.com|here> now",
},
{
name: "header to bold",
input: "# Header One",
expected: "*Header One*",
},
{
name: "header level 2",
input: "## Header Two",
expected: "*Header Two*",
},
{
name: "bullet list",
input: "- item one\n- item two",
expected: "• item one\n• item two",
},
{
name: "mixed formatting",
input: "**bold** and *italic* and [link](http://x.com)",
expected: "*bold* and _italic_ and <http://x.com|link>",
},
{
name: "code block unchanged",
input: "```\ncode here\n```",
expected: "```\ncode here\n```",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := convertMarkdownToMrkdwn(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestSplitContentWithTables(t *testing.T) {
tests := []struct {
name string
input string
expectedCount int
expectedTables int
}{
{
name: "no table",
input: "Just some text",
expectedCount: 1,
expectedTables: 0,
},
{
name: "simple table",
input: "| A | B |\n|---|---|\n| 1 | 2 |",
expectedCount: 1,
expectedTables: 1,
},
{
name: "text before table",
input: "Intro text\n\n| A | B |\n|---|---|\n| 1 | 2 |",
expectedCount: 2,
expectedTables: 1,
},
{
name: "text before and after table",
input: "Before\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nAfter",
expectedCount: 3,
expectedTables: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
segments := splitContentWithTables(tt.input)
assert.Equal(t, tt.expectedCount, len(segments))
tableCount := 0
for _, seg := range segments {
if seg.isTable {
tableCount++
}
}
assert.Equal(t, tt.expectedTables, tableCount)
})
}
}
func TestRenderTable(t *testing.T) {
tests := []struct {
name string
input string
expectCode bool
}{
{
name: "narrow table renders as text",
input: "| A | B |\n|---|---|\n| 1 | 2 |",
expectCode: false,
},
{
name: "wide table renders as code block",
input: "| This is a very long column header | Another extremely long column header here |\n|---|---|\n| Some long value content here | More long value content |",
expectCode: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := renderTable(tt.input)
if tt.expectCode {
assert.Contains(t, result, "```")
} else {
assert.NotContains(t, result, "```")
assert.Contains(t, result, "*") // Bold headers
}
})
}
}
func TestRenderTable_Alignment(t *testing.T) {
input := "| Name | Status | Count |\n|---|---|---|\n| foo | OK | 1 |\n| barbaz | PENDING | 123 |"
result := renderTable(input)
// Should be mrkdwn (narrow table)
assert.NotContains(t, result, "```")
assert.Contains(t, result, "*Name*")
// Test wide table alignment
wideInput := "| This is a very long column header | Another extremely long column header here |\n|---|---|\n| Short | Longer value here |"
wideResult := renderTable(wideInput)
assert.Contains(t, wideResult, "```")
// Check that columns are padded - header and value should have same column width
lines := strings.Split(wideResult, "\n")
// Find the header line and a data line
var headerLine, dataLine string
for _, line := range lines {
if strings.Contains(line, "This is a very long") {
headerLine = line
}
if strings.Contains(line, "Short") {
dataLine = line
}
}
// Both lines should have same length (aligned columns)
assert.Equal(t, len(headerLine), len(dataLine), "columns should be aligned")
}

View file

@ -0,0 +1,32 @@
package slackwebhook
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory(
config.ChannelSlackWebHook,
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
bc := cfg.Channels[channelName]
decoded, err := bc.GetDecoded()
if err != nil {
return nil, err
}
c, ok := decoded.(*config.SlackWebhookSettings)
if !ok {
return nil, channels.ErrSendFailed
}
ch, err := NewSlackWebhookChannel(bc, c, b)
if err != nil {
return nil, err
}
if channelName != config.ChannelSlackWebHook {
ch.SetName(channelName)
}
return ch, nil
},
)
}

View file

@ -0,0 +1,316 @@
package slackwebhook
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
const maxTextBlockLength = 3000
// SlackWebhookChannel is an output-only channel that sends messages
// to Slack via Incoming Webhooks using Block Kit formatting.
type SlackWebhookChannel struct {
*channels.BaseChannel
bc *config.Channel
config *config.SlackWebhookSettings
client *http.Client
}
// NewSlackWebhookChannel creates a new Slack webhook channel.
func NewSlackWebhookChannel(
bc *config.Channel,
cfg *config.SlackWebhookSettings,
bus *bus.MessageBus,
) (*SlackWebhookChannel, error) {
if len(cfg.Webhooks) == 0 {
return nil, fmt.Errorf("slack_webhook: at least one webhook target is required")
}
if _, hasDefault := cfg.Webhooks["default"]; !hasDefault {
return nil, fmt.Errorf("slack_webhook: a 'default' webhook target is required")
}
for name, target := range cfg.Webhooks {
webhookURL := target.WebhookURL.String()
if webhookURL == "" {
return nil, fmt.Errorf("slack_webhook: webhook %q has empty webhook_url", name)
}
parsed, err := url.Parse(webhookURL)
if err != nil {
return nil, fmt.Errorf("slack_webhook: webhook %q has invalid URL format: %w", name, err)
}
if !strings.EqualFold(parsed.Scheme, "https") {
return nil, fmt.Errorf("slack_webhook: webhook %q must use HTTPS (got %q)", name, parsed.Scheme)
}
}
base := channels.NewBaseChannel(
"slack_webhook",
cfg,
bus,
[]string{"*"},
channels.WithMaxMessageLength(40000),
)
return &SlackWebhookChannel{
BaseChannel: base,
bc: bc,
config: cfg,
client: &http.Client{
Timeout: 30 * time.Second,
},
}, nil
}
// Start initializes the channel. For output-only channels, this is a no-op.
func (c *SlackWebhookChannel) Start(ctx context.Context) error {
targets := make([]string, 0, len(c.config.Webhooks))
for name := range c.config.Webhooks {
targets = append(targets, name)
}
sort.Strings(targets)
logger.InfoCF("slack_webhook", "Starting Slack webhook channel (output-only)", map[string]any{
"targets": targets,
})
c.SetRunning(true)
return nil
}
// Stop shuts down the channel.
func (c *SlackWebhookChannel) Stop(ctx context.Context) error {
logger.InfoC("slack_webhook", "Stopping Slack webhook channel")
c.SetRunning(false)
return nil
}
// Send delivers a message to the specified Slack webhook target.
func (c *SlackWebhookChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
if !c.IsRunning() {
return nil, channels.ErrNotRunning
}
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
targetName := msg.ChatID
if targetName == "" {
targetName = "default"
}
target, ok := c.config.Webhooks[targetName]
if !ok {
logger.WarnCF("slack_webhook", "Unknown target, falling back to default", map[string]any{
"requested": msg.ChatID,
"using": "default",
})
target = c.config.Webhooks["default"]
targetName = "default"
}
payload := c.buildPayload(msg, target)
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("slack_webhook: failed to marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.WebhookURL.String(), bytes.NewReader(jsonData))
if err != nil {
return nil, fmt.Errorf("slack_webhook: failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.client.Do(req)
if err != nil {
logger.ErrorCF("slack_webhook", "Failed to send message", map[string]any{
"target": targetName,
})
// Don't expose raw error - it may contain webhook URL secrets
return nil, fmt.Errorf("slack_webhook: network error: %w", channels.ErrTemporary)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
respText := strings.TrimSpace(string(respBody))
if respText == "" {
respText = http.StatusText(resp.StatusCode)
if respText == "" {
respText = "unknown error"
}
}
logger.ErrorCF("slack_webhook", "Slack API error", map[string]any{
"target": targetName,
"status": resp.StatusCode,
"response": respText,
})
sendErr := fmt.Errorf("status %d: %s", resp.StatusCode, respText)
return nil, fmt.Errorf("slack_webhook: %w", channels.ClassifySendError(resp.StatusCode, sendErr))
}
logger.DebugCF("slack_webhook", "Message sent successfully", map[string]any{
"target": targetName,
})
return nil, nil
}
func (c *SlackWebhookChannel) buildPayload(msg bus.OutboundMessage, target config.SlackWebhookTarget) map[string]any {
payload := make(map[string]any)
if target.Username != "" {
payload["username"] = target.Username
}
if target.IconEmoji != "" {
payload["icon_emoji"] = target.IconEmoji
}
content := msg.Content
if content == "" {
content = "(empty message)"
}
blocks := c.buildBlocks(content)
payload["blocks"] = blocks
return payload
}
func (c *SlackWebhookChannel) buildBlocks(content string) []map[string]any {
var blocks []map[string]any
segments := splitContentWithTables(content)
for _, seg := range segments {
if seg.isTable {
tableText := renderTable(seg.content)
for _, chunk := range splitText(tableText, maxTextBlockLength) {
blocks = append(blocks, c.textSection(chunk))
}
} else {
text := strings.TrimSpace(seg.content)
if text == "" {
continue
}
converted := convertMarkdownToMrkdwn(text)
for _, chunk := range splitText(converted, maxTextBlockLength) {
blocks = append(blocks, c.textSection(chunk))
}
}
}
if len(blocks) == 0 {
blocks = append(blocks, c.textSection("(empty message)"))
}
return blocks
}
func (c *SlackWebhookChannel) textSection(text string) map[string]any {
return map[string]any{
"type": "section",
"text": map[string]any{
"type": "mrkdwn",
"text": text,
},
}
}
func splitText(text string, maxLen int) []string {
runes := []rune(text)
if len(runes) <= maxLen {
return []string{text}
}
const fencePrefix = "```\n"
const fenceSuffix = "\n```"
fencePrefixLen := len([]rune(fencePrefix))
fenceSuffixLen := len([]rune(fenceSuffix))
var chunks []string
inFence := false
for len(runes) > 0 {
// Calculate content budget reserving space for fence markers
prefixLen := 0
if inFence {
prefixLen = fencePrefixLen
}
contentBudget := maxLen - prefixLen - fenceSuffixLen
if contentBudget <= 0 {
contentBudget = maxLen
}
splitAt := len(runes)
if splitAt > contentBudget {
splitAt = findSplitPoint(runes, contentBudget)
if splitAt <= 0 || splitAt > contentBudget {
splitAt = contentBudget
}
}
chunkBody := string(runes[:splitAt])
chunkEndsInFence := endsInsideFence(chunkBody, inFence)
chunk := wrapFenceChunk(chunkBody, inFence, chunkEndsInFence)
chunks = append(chunks, chunk)
inFence = chunkEndsInFence
runes = runes[splitAt:]
}
return chunks
}
func wrapFenceChunk(text string, wasInFence bool, endsInFence bool) string {
if wasInFence && !strings.HasPrefix(strings.TrimSpace(text), "```") {
text = "```\n" + text
}
if endsInFence {
text = strings.TrimSuffix(text, "\n") + "\n```"
}
return text
}
func findSplitPoint(runes []rune, maxLen int) int {
if len(runes) <= maxLen {
return len(runes)
}
window := string(runes[:maxLen])
// Try splitting on newline
if idx := strings.LastIndex(window, "\n"); idx > 0 {
return len([]rune(window[:idx])) + 1
}
// Try splitting on space
if idx := strings.LastIndex(window, " "); idx > 0 {
return len([]rune(window[:idx])) + 1
}
// Try to split before a fence marker
if idx := strings.LastIndex(window, "```"); idx > 0 {
return len([]rune(window[:idx]))
}
return maxLen
}
func endsInsideFence(text string, wasInFence bool) bool {
return wasInFence != (strings.Count(text, "```")%2 == 1)
}

View file

@ -0,0 +1,281 @@
package slackwebhook
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestNewSlackWebhookChannel_Validation(t *testing.T) {
tests := []struct {
name string
webhooks map[string]config.SlackWebhookTarget
expectErr string
}{
{
name: "empty webhooks",
webhooks: map[string]config.SlackWebhookTarget{},
expectErr: "at least one webhook target is required",
},
{
name: "missing default",
webhooks: map[string]config.SlackWebhookTarget{
"alerts": {
WebhookURL: *config.NewSecureString("https://hooks.slack.com/services/T/B/x"),
},
},
expectErr: "a 'default' webhook target is required",
},
{
name: "empty webhook URL",
webhooks: map[string]config.SlackWebhookTarget{
"default": {WebhookURL: *config.NewSecureString("")},
},
expectErr: "has empty webhook_url",
},
{
name: "non-HTTPS URL",
webhooks: map[string]config.SlackWebhookTarget{
"default": {
WebhookURL: *config.NewSecureString("http://hooks.slack.com/services/T/B/x"),
},
},
expectErr: "must use HTTPS",
},
{
name: "valid config",
webhooks: map[string]config.SlackWebhookTarget{
"default": {
WebhookURL: *config.NewSecureString("https://hooks.slack.com/services/T/B/x"),
Username: "TestBot",
IconEmoji: ":robot_face:",
},
},
expectErr: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &config.SlackWebhookSettings{Webhooks: tt.webhooks}
bc := &config.Channel{Enabled: true}
mb := bus.NewMessageBus()
ch, err := NewSlackWebhookChannel(bc, cfg, mb)
if tt.expectErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.expectErr)
} else {
require.NoError(t, err)
assert.NotNil(t, ch)
}
})
}
}
func TestSlackWebhookChannel_Send(t *testing.T) {
payloadCh := make(chan map[string]any, 1)
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var payload map[string]any
json.Unmarshal(body, &payload)
payloadCh <- payload
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
cfg := &config.SlackWebhookSettings{
Webhooks: map[string]config.SlackWebhookTarget{
"default": {
WebhookURL: *config.NewSecureString(server.URL),
Username: "TestBot",
IconEmoji: ":test:",
},
},
}
bc := &config.Channel{Enabled: true}
mb := bus.NewMessageBus()
ch, err := NewSlackWebhookChannel(bc, cfg, mb)
require.NoError(t, err)
// Use the test server's client to skip TLS verification
ch.client = server.Client()
err = ch.Start(context.Background())
require.NoError(t, err)
_, err = ch.Send(context.Background(), bus.OutboundMessage{
Content: "Hello **world**",
ChatID: "default",
})
require.NoError(t, err)
// Verify payload structure
receivedPayload := <-payloadCh
assert.Equal(t, "TestBot", receivedPayload["username"])
assert.Equal(t, ":test:", receivedPayload["icon_emoji"])
blocks, ok := receivedPayload["blocks"].([]any)
require.True(t, ok)
require.Len(t, blocks, 1)
}
func TestSlackWebhookChannel_FallbackToDefault(t *testing.T) {
var requestCount atomic.Int32
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount.Add(1)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
cfg := &config.SlackWebhookSettings{
Webhooks: map[string]config.SlackWebhookTarget{
"default": {WebhookURL: *config.NewSecureString(server.URL)},
},
}
bc := &config.Channel{Enabled: true}
mb := bus.NewMessageBus()
ch, err := NewSlackWebhookChannel(bc, cfg, mb)
require.NoError(t, err)
ch.client = server.Client()
err = ch.Start(context.Background())
require.NoError(t, err)
// Send to unknown target - should fall back to default
_, err = ch.Send(context.Background(), bus.OutboundMessage{
Content: "Test",
ChatID: "unknown_target",
})
require.NoError(t, err)
assert.Equal(t, int32(1), requestCount.Load())
}
func TestSlackWebhookChannel_ErrorClassification(t *testing.T) {
tests := []struct {
name string
statusCode int
expectTemp bool
}{
{"400 Bad Request", 400, false},
{"401 Unauthorized", 401, false},
{"403 Forbidden", 403, false},
{"404 Not Found", 404, false},
{"500 Internal Error", 500, true},
{"502 Bad Gateway", 502, true},
{"503 Service Unavailable", 503, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
server := httptest.NewTLSServer(
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(tt.statusCode)
}),
)
defer server.Close()
cfg := &config.SlackWebhookSettings{
Webhooks: map[string]config.SlackWebhookTarget{
"default": {WebhookURL: *config.NewSecureString(server.URL)},
},
}
bc := &config.Channel{Enabled: true}
mb := bus.NewMessageBus()
ch, err := NewSlackWebhookChannel(bc, cfg, mb)
require.NoError(t, err)
ch.client = server.Client()
err = ch.Start(context.Background())
require.NoError(t, err)
_, err = ch.Send(context.Background(), bus.OutboundMessage{Content: "Test"})
require.Error(t, err)
if tt.expectTemp {
assert.True(
t,
errors.Is(err, channels.ErrTemporary),
"expected temporary error for %d",
tt.statusCode,
)
} else {
assert.True(t, errors.Is(err, channels.ErrSendFailed), "expected permanent error for %d", tt.statusCode)
}
})
}
}
func TestSplitText_ChunkSizeLimit(t *testing.T) {
tests := []struct {
name string
input string
maxLen int
}{
{
name: "plain text",
input: strings.Repeat("a", 5000),
maxLen: 3000,
},
{
name: "text with code block",
input: "```\n" + strings.Repeat("x", 5000) + "\n```",
maxLen: 3000,
},
{
name: "multiple code blocks",
input: "text\n```\n" + strings.Repeat(
"code ",
800,
) + "\n```\nmore text\n```\n" + strings.Repeat(
"more ",
800,
) + "\n```",
maxLen: 3000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
chunks := splitText(tt.input, tt.maxLen)
for i, chunk := range chunks {
runeLen := len([]rune(chunk))
assert.LessOrEqual(t, runeLen, tt.maxLen,
"chunk %d has %d runes, exceeds max %d", i, runeLen, tt.maxLen)
}
})
}
}
func TestSplitText_FenceIntegrity(t *testing.T) {
input := "```\n" + strings.Repeat("line of code\n", 300) + "```"
chunks := splitText(input, 3000)
require.Greater(t, len(chunks), 1, "expected multiple chunks")
for i, chunk := range chunks {
openCount := strings.Count(chunk, "```")
assert.Equal(t, 0, openCount%2,
"chunk %d has unbalanced fence markers (count=%d)", i, openCount)
}
}
func TestSplitText_ShortText(t *testing.T) {
input := "short text"
chunks := splitText(input, 3000)
require.Len(t, chunks, 1)
assert.Equal(t, input, chunks[0])
}

View file

@ -11,6 +11,7 @@ import (
"net/url"
"os"
"regexp"
"slices"
"strconv"
"strings"
"sync"
@ -43,20 +44,38 @@ var (
reInlineCode = regexp.MustCompile("`([^`]+)`")
)
const defaultMediaGroupDelay = 500 * time.Millisecond
type TelegramChannel struct {
*channels.BaseChannel
bot *telego.Bot
bh *th.BotHandler
bc *config.Channel
chatIDs map[string]int64
ctx context.Context
cancel context.CancelFunc
tgCfg *config.TelegramSettings
progress *channels.ToolFeedbackAnimator
bot *telego.Bot
bh *th.BotHandler
bc *config.Channel
chatIDsMu sync.Mutex
chatIDs map[string]int64
ctx context.Context
cancel context.CancelFunc
tgCfg *config.TelegramSettings
progress *channels.ToolFeedbackAnimator
registerFunc func(context.Context, []commands.Definition) error
commandRegDelayFn func(int) time.Duration
commandRegCancel context.CancelFunc
mediaGroupMu sync.Mutex
mediaGroups map[string]*telegramMediaGroup
mediaGroupDelay time.Duration
}
type telegramMediaGroup struct {
messages []*telego.Message
timer *time.Timer
generation uint64
}
type telegramMessageParts struct {
content []string
mediaPaths []string
}
func NewTelegramChannel(
@ -112,11 +131,21 @@ func NewTelegramChannel(
bc: bc,
chatIDs: make(map[string]int64),
tgCfg: telegramCfg,
mediaGroups: make(map[string]*telegramMediaGroup),
mediaGroupDelay: telegramMediaGroupDelay(telegramCfg),
}
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
return ch, nil
}
func telegramMediaGroupDelay(telegramCfg *config.TelegramSettings) time.Duration {
if telegramCfg != nil && telegramCfg.MediaGroupDelayMS > 0 {
return time.Duration(telegramCfg.MediaGroupDelayMS) * time.Millisecond
}
return defaultMediaGroupDelay
}
func (c *TelegramChannel) Start(ctx context.Context) error {
logger.InfoC("telegram", "Starting Telegram bot (polling mode)...")
@ -167,6 +196,7 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
if c.bh != nil {
_ = c.bh.StopWithContext(ctx)
}
c.flushPendingMediaGroups(ctx)
// Cancel our context (stops long polling)
if c.cancel != nil {
@ -713,6 +743,131 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
}
func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
if message != nil && strings.TrimSpace(message.MediaGroupID) != "" {
return c.bufferMediaGroupMessage(ctx, message)
}
return c.handleMessages(ctx, []*telego.Message{message})
}
func (c *TelegramChannel) bufferMediaGroupMessage(ctx context.Context, message *telego.Message) error {
if message == nil {
return fmt.Errorf("message is nil")
}
groupID := strings.TrimSpace(message.MediaGroupID)
if groupID == "" {
return c.handleMessages(ctx, []*telego.Message{message})
}
msgCopy := *message
msgCopy.Photo = append([]telego.PhotoSize(nil), message.Photo...)
key := fmt.Sprintf("%d:%s", message.Chat.ID, groupID)
c.mediaGroupMu.Lock()
if c.mediaGroups == nil {
c.mediaGroups = make(map[string]*telegramMediaGroup)
}
group := c.mediaGroups[key]
if group == nil {
group = &telegramMediaGroup{}
c.mediaGroups[key] = group
}
group.messages = append(group.messages, &msgCopy)
group.generation++
generation := group.generation
if group.timer != nil {
group.timer.Stop()
}
delay := c.mediaGroupDelay
if delay <= 0 {
delay = defaultMediaGroupDelay
}
group.timer = time.AfterFunc(delay, func() {
c.flushMediaGroup(c.ctx, key, generation)
})
c.mediaGroupMu.Unlock()
logger.DebugCF("telegram", "Buffered media group message", map[string]any{
"chat_id": message.Chat.ID,
"media_group_id": groupID,
"message_id": message.MessageID,
})
return nil
}
func (c *TelegramChannel) flushPendingMediaGroups(ctx context.Context) {
c.mediaGroupMu.Lock()
keys := make([]string, 0, len(c.mediaGroups))
for key, group := range c.mediaGroups {
if group.timer != nil {
group.timer.Stop()
}
keys = append(keys, key)
}
c.mediaGroupMu.Unlock()
for _, key := range keys {
c.flushMediaGroup(ctx, key, 0)
}
}
func (c *TelegramChannel) flushMediaGroup(ctx context.Context, key string, generation uint64) {
c.mediaGroupMu.Lock()
group := c.mediaGroups[key]
if group == nil {
c.mediaGroupMu.Unlock()
return
}
if generation != 0 && group.generation != generation {
c.mediaGroupMu.Unlock()
return
}
delete(c.mediaGroups, key)
if group.timer != nil {
group.timer.Stop()
}
messages := append([]*telego.Message(nil), group.messages...)
c.mediaGroupMu.Unlock()
if len(messages) == 0 {
return
}
slices.SortFunc(messages, func(a, b *telego.Message) int {
switch {
case a == nil && b == nil:
return 0
case a == nil:
return -1
case b == nil:
return 1
default:
return a.MessageID - b.MessageID
}
})
if ctx == nil {
ctx = context.Background()
}
if err := c.handleMessages(ctx, messages); err != nil {
logger.ErrorCF("telegram", "Failed to handle media group", map[string]any{
"key": key,
"error": err.Error(),
})
}
}
func (c *TelegramChannel) handleMessages(ctx context.Context, messages []*telego.Message) error {
if len(messages) == 0 {
return nil
}
message := messages[0]
for _, candidate := range messages {
if candidate == nil {
continue
}
if strings.TrimSpace(candidate.Text) != "" || strings.TrimSpace(candidate.Caption) != "" {
message = candidate
break
}
}
if message == nil {
return fmt.Errorf("message is nil")
}
@ -740,7 +895,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
}
chatID := message.Chat.ID
c.chatIDsMu.Lock()
c.chatIDs[platformID] = chatID
c.chatIDsMu.Unlock()
content := ""
mediaPaths := []string{}
@ -764,61 +921,18 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
return localPath // fallback: use raw path
}
if message.Text != "" {
content += message.Text
}
if message.Caption != "" {
if content != "" {
content += "\n"
for i, msg := range messages {
if msg == nil {
continue
}
content += message.Caption
}
if len(message.Photo) > 0 {
photo := message.Photo[len(message.Photo)-1]
photoPath := c.downloadPhoto(ctx, photo.FileID)
if photoPath != "" {
mediaPaths = append(mediaPaths, storeMedia(photoPath, "photo.jpg"))
parts := c.collectTelegramMessageParts(ctx, msg, i, len(messages), storeMedia)
for _, part := range parts.content {
if content != "" {
content += "\n"
}
content += "[image: photo]"
}
}
if message.Voice != nil {
voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg")
if voicePath != "" {
mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg"))
if content != "" {
content += "\n"
}
content += "[voice]"
}
}
if message.Audio != nil {
audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3")
if audioPath != "" {
mediaPaths = append(mediaPaths, storeMedia(audioPath, "audio.mp3"))
if content != "" {
content += "\n"
}
content += "[audio]"
}
}
if message.Document != nil {
docPath := c.downloadFile(ctx, message.Document.FileID, "")
if docPath != "" {
mediaPaths = append(mediaPaths, storeMedia(docPath, "document"))
if content != "" {
content += "\n"
}
content += "[file]"
content += part
}
mediaPaths = append(mediaPaths, parts.mediaPaths...)
}
if content == "" && len(mediaPaths) == 0 {
@ -917,6 +1031,74 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
return nil
}
func (c *TelegramChannel) collectTelegramMessageParts(
ctx context.Context,
msg *telego.Message,
index int,
total int,
storeMedia func(localPath, filename string) string,
) telegramMessageParts {
parts := telegramMessageParts{}
if msg == nil {
return parts
}
if text := strings.TrimSpace(msg.Text); text != "" {
parts.content = append(parts.content, text)
}
if caption := strings.TrimSpace(msg.Caption); caption != "" {
parts.content = append(parts.content, caption)
}
if len(msg.Photo) > 0 {
photo := msg.Photo[len(msg.Photo)-1]
photoPath := c.downloadPhoto(ctx, photo.FileID)
if photoPath != "" {
photoNumber := index + 1
parts.mediaPaths = append(parts.mediaPaths, storeMedia(photoPath, fmt.Sprintf("photo-%d.jpg", photoNumber)))
parts.content = append(parts.content, fmt.Sprintf("[image: photo %d]", photoNumber))
}
}
if msg.Voice != nil {
voicePath := c.downloadFile(ctx, msg.Voice.FileID, ".ogg")
if voicePath != "" {
parts.mediaPaths = append(
parts.mediaPaths,
storeMedia(voicePath, indexedMediaFilename("voice", ".ogg", index, total)),
)
parts.content = append(parts.content, "[voice]")
}
}
if msg.Audio != nil {
audioPath := c.downloadFile(ctx, msg.Audio.FileID, ".mp3")
if audioPath != "" {
filename := msg.Audio.FileName
if strings.TrimSpace(filename) == "" {
filename = indexedMediaFilename("audio", ".mp3", index, total)
}
parts.mediaPaths = append(parts.mediaPaths, storeMedia(audioPath, filename))
parts.content = append(parts.content, "[audio]")
}
}
if msg.Document != nil {
docPath := c.downloadFile(ctx, msg.Document.FileID, "")
if docPath != "" {
filename := msg.Document.FileName
if strings.TrimSpace(filename) == "" {
filename = indexedMediaFilename("document", "", index, total)
}
parts.mediaPaths = append(parts.mediaPaths, storeMedia(docPath, filename))
parts.content = append(parts.content, "[file]")
}
}
return parts
}
func indexedMediaFilename(prefix, ext string, index int, total int) string {
if total <= 1 {
return prefix + ext
}
return fmt.Sprintf("%s-%d%s", prefix, index+1, ext)
}
func (c *TelegramChannel) prependTelegramQuotedReply(content string, reply *telego.Message) string {
quoted := strings.TrimSpace(telegramQuotedContent(reply))
if quoted == "" {

View file

@ -10,6 +10,7 @@ import (
"strconv"
"strings"
"testing"
"time"
"github.com/mymmrac/telego"
ta "github.com/mymmrac/telego/telegoapi"
@ -1100,3 +1101,190 @@ func TestHandleMessage_EmptyContent_Ignored(t *testing.T) {
default:
}
}
func TestHandleMessage_MediaGroupCombinesCaptionMessages(t *testing.T) {
messageBus, ch := newMediaGroupTestChannel(10 * time.Millisecond)
base := testMediaGroupMessage("album-1")
first := base
first.MessageID = 1
second := base
second.MessageID = 2
second.Caption = "meal caption"
require.NoError(t, ch.handleMessage(context.Background(), &first))
require.NoError(t, ch.handleMessage(context.Background(), &second))
select {
case inbound := <-messageBus.InboundChan():
assert.Equal(t, "2", inbound.Context.MessageID)
assert.Equal(t, "meal caption", inbound.Content)
case <-time.After(time.Second):
t.Fatal("timed out waiting for combined media group message")
}
}
func TestHandleMessage_MediaGroupWaitsForStaggeredMessages(t *testing.T) {
messageBus, ch := newMediaGroupTestChannel(100 * time.Millisecond)
base := testMediaGroupMessage("album-staggered")
first := base
first.MessageID = 1
first.Caption = "first caption"
second := base
second.MessageID = 2
second.Caption = "second caption"
require.NoError(t, ch.handleMessage(context.Background(), &first))
time.Sleep(50 * time.Millisecond)
require.NoError(t, ch.handleMessage(context.Background(), &second))
select {
case inbound := <-messageBus.InboundChan():
t.Fatalf("media group flushed before idle delay reset: %#v", inbound)
case <-time.After(75 * time.Millisecond):
}
select {
case inbound := <-messageBus.InboundChan():
assert.Equal(t, "1", inbound.Context.MessageID)
assert.Equal(t, "first caption\nsecond caption", inbound.Content)
case <-time.After(time.Second):
t.Fatal("timed out waiting for staggered media group message")
}
}
func TestFlushMediaGroupIgnoresStaleTimerGeneration(t *testing.T) {
messageBus, ch := newMediaGroupTestChannel(time.Hour)
base := testMediaGroupMessage("album-generation")
first := base
first.MessageID = 1
first.Caption = "first"
second := base
second.MessageID = 2
second.Caption = "second"
key := "456:album-generation"
ch.mediaGroupMu.Lock()
ch.mediaGroups[key] = &telegramMediaGroup{
messages: []*telego.Message{&first, &second},
generation: 2,
}
ch.mediaGroupMu.Unlock()
ch.flushMediaGroup(context.Background(), key, 1)
select {
case inbound := <-messageBus.InboundChan():
t.Fatalf("stale media group generation flushed unexpectedly: %#v", inbound)
default:
}
ch.mediaGroupMu.Lock()
_, stillPending := ch.mediaGroups[key]
ch.mediaGroupMu.Unlock()
require.True(t, stillPending, "stale flush should leave the current batch pending")
ch.flushMediaGroup(context.Background(), key, 2)
select {
case inbound := <-messageBus.InboundChan():
assert.Equal(t, "1", inbound.Context.MessageID)
assert.Equal(t, "first\nsecond", inbound.Content)
case <-time.After(time.Second):
t.Fatal("timed out waiting for current generation media group flush")
}
}
func TestHandleMessage_MediaGroupAfterDelayStartsNewBatch(t *testing.T) {
messageBus, ch := newMediaGroupTestChannel(10 * time.Millisecond)
base := testMediaGroupMessage("album-split")
first := base
first.MessageID = 1
first.Caption = "first"
second := base
second.MessageID = 2
second.Caption = "second"
require.NoError(t, ch.handleMessage(context.Background(), &first))
select {
case inbound := <-messageBus.InboundChan():
assert.Equal(t, "1", inbound.Context.MessageID)
assert.Equal(t, "first", inbound.Content)
case <-time.After(time.Second):
t.Fatal("timed out waiting for first media group batch")
}
require.NoError(t, ch.handleMessage(context.Background(), &second))
select {
case inbound := <-messageBus.InboundChan():
assert.Equal(t, "2", inbound.Context.MessageID)
assert.Equal(t, "second", inbound.Content)
case <-time.After(time.Second):
t.Fatal("timed out waiting for second media group batch")
}
}
func TestStopFlushesPendingMediaGroups(t *testing.T) {
messageBus, ch := newMediaGroupTestChannel(time.Hour)
base := testMediaGroupMessage("album-stop")
msg := base
msg.MessageID = 1
msg.Caption = "caption before stop"
require.NoError(t, ch.handleMessage(context.Background(), &msg))
require.NoError(t, ch.Stop(context.Background()))
select {
case inbound := <-messageBus.InboundChan():
assert.Equal(t, "1", inbound.Context.MessageID)
assert.Equal(t, "caption before stop", inbound.Content)
case <-time.After(time.Second):
t.Fatal("timed out waiting for pending media group flush on stop")
}
}
func TestNewTelegramChannelUsesConfiguredMediaGroupDelay(t *testing.T) {
ch, err := NewTelegramChannel(
&config.Channel{Type: config.ChannelTelegram, Enabled: true},
&config.TelegramSettings{
Token: *config.NewSecureString(testToken),
MediaGroupDelayMS: 750,
},
bus.NewMessageBus(),
)
require.NoError(t, err)
assert.Equal(t, 750*time.Millisecond, ch.mediaGroupDelay)
ch, err = NewTelegramChannel(
&config.Channel{Type: config.ChannelTelegram, Enabled: true},
&config.TelegramSettings{Token: *config.NewSecureString(testToken)},
bus.NewMessageBus(),
)
require.NoError(t, err)
assert.Equal(t, defaultMediaGroupDelay, ch.mediaGroupDelay)
}
func newMediaGroupTestChannel(delay time.Duration) (*bus.MessageBus, *TelegramChannel) {
messageBus := bus.NewMessageBus()
ch := &TelegramChannel{
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
chatIDs: make(map[string]int64),
ctx: context.Background(),
mediaGroups: make(map[string]*telegramMediaGroup),
mediaGroupDelay: delay,
}
return messageBus, ch
}
func testMediaGroupMessage(mediaGroupID string) telego.Message {
return telego.Message{
Chat: telego.Chat{
ID: 456,
Type: "private",
},
From: &telego.User{
ID: 789,
FirstName: "User",
},
MediaGroupID: mediaGroupID,
}
}

View file

@ -8,6 +8,7 @@ func BuiltinDefinitions() []Definition {
return []Definition{
startCommand(),
helpCommand(),
stopCommand(),
showCommand(),
listCommand(),
useCommand(),

View file

@ -42,6 +42,9 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
if !strings.Contains(reply, "/list [models|channels|agents|skills|mcp]") {
t.Fatalf("/help reply missing /list usage, got %q", reply)
}
if !strings.Contains(reply, "/stop") {
t.Fatalf("/help reply missing /stop usage, got %q", reply)
}
if !strings.Contains(reply, "/use <skill> <message>") {
if !strings.Contains(reply, "/use <skill> [message]") {
t.Fatalf("/help reply missing /use usage, got %q", reply)
@ -49,6 +52,59 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
}
}
func TestBuiltinStop_UsesRuntimeStopper(t *testing.T) {
rt := &Runtime{
StopActiveTurn: func() (StopResult, error) {
return StopResult{
Stopped: true,
TaskName: "sync the long running job",
}, nil
},
}
defs := BuiltinDefinitions()
ex := NewExecutor(NewRegistry(defs), rt)
var reply string
res := ex.Execute(context.Background(), Request{
Text: "/stop",
Reply: func(text string) error {
reply = text
return nil
},
})
if res.Outcome != OutcomeHandled {
t.Fatalf("/stop: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
}
if reply != "Task stopped. \"sync the long running job\" was canceled." {
t.Fatalf("/stop reply=%q", reply)
}
}
func TestBuiltinStop_NoActiveTask(t *testing.T) {
rt := &Runtime{
StopActiveTurn: func() (StopResult, error) {
return StopResult{}, nil
},
}
defs := BuiltinDefinitions()
ex := NewExecutor(NewRegistry(defs), rt)
var reply string
res := ex.Execute(context.Background(), Request{
Text: "/stop",
Reply: func(text string) error {
reply = text
return nil
},
})
if res.Outcome != OutcomeHandled {
t.Fatalf("/stop: outcome=%v, want=%v", res.Outcome, OutcomeHandled)
}
if reply != "No active task to stop." {
t.Fatalf("/stop reply=%q, want no-active message", reply)
}
}
func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) {
defs := BuiltinDefinitions()
ex := NewExecutor(NewRegistry(defs), nil)

52
pkg/commands/cmd_stop.go Normal file
View file

@ -0,0 +1,52 @@
package commands
import (
"context"
"fmt"
"strings"
)
func stopCommand() Definition {
return Definition{
Name: "stop",
Description: "Stop the current task",
Usage: "/stop",
Handler: func(_ context.Context, req Request, rt *Runtime) error {
if rt == nil || rt.StopActiveTurn == nil {
return req.Reply(unavailableMsg)
}
result, err := rt.StopActiveTurn()
if err != nil {
return req.Reply("Failed to stop task: " + err.Error())
}
return req.Reply(FormatStopReply(result))
},
}
}
// FormatStopReply renders a user-facing reply for a stop request.
func FormatStopReply(result StopResult) string {
if !result.Stopped {
return "No active task to stop."
}
taskName := compactStopTaskName(result.TaskName)
if taskName == "" {
return "Task stopped. Current task was canceled."
}
return fmt.Sprintf("Task stopped. %q was canceled.", taskName)
}
func compactStopTaskName(taskName string) string {
taskName = strings.Join(strings.Fields(strings.TrimSpace(taskName)), " ")
if taskName == "" {
return ""
}
if len(taskName) > 80 {
return taskName[:77] + "..."
}
return taskName
}

Some files were not shown because too many files have changed in this diff Show more