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:
commit
38063f4419
209 changed files with 30250 additions and 1730 deletions
15
README.md
15
README.md
|
|
@ -56,6 +56,14 @@
|
||||||
|
|
||||||
## 📢 News
|
## 📢 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-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**!
|
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)
|
## 💬 Channels (Chat Apps)
|
||||||
|
|
||||||
Talk to your PicoClaw through 18+ messaging platforms:
|
Talk to your PicoClaw through 19+ messaging platforms:
|
||||||
|
|
||||||
| Channel | Setup | Protocol | Docs |
|
| 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) |
|
| **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) |
|
| **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) |
|
| **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) |
|
| **MaixCam** | Easy (enable) | TCP socket | [Guide](docs/channels/maixcam/README.md) |
|
||||||
| **Pico** | Easy (enable) | Native protocol | Built-in |
|
| **Pico** | Easy (enable) | Native protocol | Built-in |
|
||||||
| **Pico Client** | Easy (WebSocket URL) | WebSocket | 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 |
|
| Search Engine | API Key | Free Tier | Link |
|
||||||
|--------------|---------|-----------|------|
|
|--------------|---------|-----------|------|
|
||||||
| DuckDuckGo | Not needed | Unlimited | Built-in fallback |
|
| 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 |
|
| [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 |
|
| [Brave Search](https://brave.com/search/api) | Required | 2000 queries/month | Fast and private |
|
||||||
| [Perplexity](https://www.perplexity.ai) | Required | Paid | AI-powered search |
|
| [Perplexity](https://www.perplexity.ai) | Required | Paid | AI-powered search |
|
||||||
|
|
@ -617,7 +626,7 @@ For detailed guides beyond this README:
|
||||||
| Topic | Description |
|
| Topic | Description |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
| [Docker & Quick Start](docs/guides/docker.md) | Docker Compose setup, Launcher/Agent modes |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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
BIN
assets/licheerv-claw.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 215 KiB |
|
|
@ -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_list": [
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.4",
|
"model_name": "gpt-5.4",
|
||||||
|
|
|
||||||
|
|
@ -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))
|
- [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))
|
- [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))
|
- [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.
|
- [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.
|
- [Agent Refactor](agent-refactor/README.md): notes and checkpoints for the agent refactor work.
|
||||||
|
|
||||||
|
|
|
||||||
47
docs/architecture/agent-self-evolution.md
Normal file
47
docs/architecture/agent-self-evolution.md
Normal 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).
|
||||||
140
docs/channels/mqtt/README.fr.md
Normal file
140
docs/channels/mqtt/README.fr.md
Normal 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.
|
||||||
140
docs/channels/mqtt/README.ja.md
Normal file
140
docs/channels/mqtt/README.ja.md
Normal 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` を設定してください。
|
||||||
142
docs/channels/mqtt/README.md
Normal file
142
docs/channels/mqtt/README.md
Normal 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.
|
||||||
140
docs/channels/mqtt/README.pt-br.md
Normal file
140
docs/channels/mqtt/README.pt-br.md
Normal 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.
|
||||||
140
docs/channels/mqtt/README.vi.md
Normal file
140
docs/channels/mqtt/README.vi.md
Normal 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.
|
||||||
142
docs/channels/mqtt/README.zh.md
Normal file
142
docs/channels/mqtt/README.zh.md
Normal 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 客户端 ID,Broker 能正确识别为同一连接。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
- **TLS**:支持 SSL/TLS(Broker 地址使用 `ssl://`),默认跳过证书验证。
|
||||||
|
- **流式响应**:流式输出时会向 response topic 发送多条消息,客户端按顺序拼接即为完整回复。
|
||||||
|
- **client_id 与会话 ID 的区别**:topic 路径中的 `client_id` 由客户端应用自行设置,用于区分会话;它与 PicoClaw paho 连接 Broker 时使用的客户端 ID 是两个独立的概念。
|
||||||
|
- **多实例部署**:若多个 PicoClaw 实例使用相同 `agent_id` 连接同一 Broker,需为每个实例配置不同的 `client_id` 以避免 Broker 层面的冲突。
|
||||||
|
|
@ -15,7 +15,8 @@ The Telegram channel uses long polling via the Telegram Bot API for bot-based co
|
||||||
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
"token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
|
||||||
"allow_from": ["123456789"],
|
"allow_from": ["123456789"],
|
||||||
"proxy": "",
|
"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 |
|
| 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) |
|
| 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 |
|
| 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
|
## Setup
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
## 💬 Applications de Chat
|
## 💬 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é.
|
> **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) |
|
| **Feishu (飞书)** | ⭐⭐⭐ Avancé | Collaboration entreprise, fonctionnalités riches | [Documentation](../channels/feishu/README.fr.md) |
|
||||||
| **IRC** | ⭐⭐ Moyen | Serveur + configuration TLS | [Documentation](#irc) |
|
| **IRC** | ⭐⭐ Moyen | Serveur + configuration TLS | [Documentation](#irc) |
|
||||||
| **OneBot** | ⭐⭐ Moyen | Compatible NapCat/Go-CQHTTP, écosystème communautaire | [Documentation](../channels/onebot/README.fr.md) |
|
| **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) |
|
| **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 | |
|
| **Pico** | ⭐ Facile | Canal protocole natif PicoClaw | |
|
||||||
|
|
||||||
|
|
@ -681,3 +682,67 @@ picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ PicoClaw は複数のチャットプラットフォームをサポートして
|
||||||
| **Feishu (飛書)** | ⭐⭐⭐ やや難 | エンタープライズコラボレーション、機能豊富 | [ドキュメント](../channels/feishu/README.ja.md) |
|
| **Feishu (飛書)** | ⭐⭐⭐ やや難 | エンタープライズコラボレーション、機能豊富 | [ドキュメント](../channels/feishu/README.ja.md) |
|
||||||
| **IRC** | ⭐⭐ 中程度 | サーバー + TLS 設定 | [ドキュメント](#irc) |
|
| **IRC** | ⭐⭐ 中程度 | サーバー + TLS 設定 | [ドキュメント](#irc) |
|
||||||
| **OneBot** | ⭐⭐ 中程度 | NapCat/Go-CQHTTP 互換、コミュニティエコシステム充実 | [ドキュメント](../channels/onebot/README.ja.md) |
|
| **OneBot** | ⭐⭐ 中程度 | NapCat/Go-CQHTTP 互換、コミュニティエコシステム充実 | [ドキュメント](../channels/onebot/README.ja.md) |
|
||||||
|
| **MQTT** | ⭐ 簡単 | ブローカー経由で任意の MQTT クライアントと通信 | [ドキュメント](../channels/mqtt/README.ja.md) |
|
||||||
| **MaixCam** | ⭐ 簡単 | Sipeed AI カメラハードウェア統合チャネル | [ドキュメント](../channels/maixcam/README.ja.md) |
|
| **MaixCam** | ⭐ 簡単 | Sipeed AI カメラハードウェア統合チャネル | [ドキュメント](../channels/maixcam/README.ja.md) |
|
||||||
| **Pico** | ⭐ 簡単 | PicoClaw ネイティブプロトコルチャネル | |
|
| **Pico** | ⭐ 簡単 | PicoClaw ネイティブプロトコルチャネル | |
|
||||||
|
|
||||||
|
|
@ -670,3 +671,67 @@ picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
## 💬 Chat Apps
|
## 💬 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.
|
> **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) |
|
| **Feishu (飞书)** | ⭐⭐⭐ Advanced | Enterprise collaboration, feature-rich | [Docs](../channels/feishu/README.md) |
|
||||||
| **IRC** | ⭐⭐ Medium | Server + TLS configuration | [Docs](#irc) |
|
| **IRC** | ⭐⭐ Medium | Server + TLS configuration | [Docs](#irc) |
|
||||||
| **OneBot** | ⭐⭐ Medium | NapCat/Go-CQHTTP compatible, community ecosystem | [Docs](../channels/onebot/README.md) |
|
| **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) |
|
| **MaixCam** | ⭐ Easy | Hardware integration channel for Sipeed AI cameras | [Docs](../channels/maixcam/README.md) |
|
||||||
| **Pico** | ⭐ Easy | Native PicoClaw protocol channel | |
|
| **Pico** | ⭐ Easy | Native PicoClaw protocol channel | |
|
||||||
|
|
||||||
|
|
@ -587,3 +588,69 @@ picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
## 💬 Aplikasi Sembang
|
## 💬 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.
|
> **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) |
|
| **Slack** | Sederhana (Bot token + App token) |
|
||||||
| **IRC** | Sederhana (pelayan + konfigurasi TLS) |
|
| **IRC** | Sederhana (pelayan + konfigurasi TLS) |
|
||||||
| **OneBot** | Sederhana (QQ melalui protokol OneBot) |
|
| **OneBot** | Sederhana (QQ melalui protokol OneBot) |
|
||||||
|
| **MQTT** | Mudah (broker + agent_id) |
|
||||||
| **MaixCam** | Mudah (integrasi perkakasan Sipeed) |
|
| **MaixCam** | Mudah (integrasi perkakasan Sipeed) |
|
||||||
| **Pico** | Protokol PicoClaw asli |
|
| **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`.
|
> **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>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
## 💬 Aplicativos de Chat
|
## 💬 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.
|
> **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) |
|
| **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) |
|
| **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) |
|
| **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) |
|
| **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 | |
|
| **Pico** | ⭐ Fácil | Canal de protocolo nativo PicoClaw | |
|
||||||
|
|
||||||
|
|
@ -695,3 +696,67 @@ picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
## 💬 Ứng Dụng Chat
|
## 💬 Ứ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.
|
> **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) |
|
| **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) |
|
| **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) |
|
| **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) |
|
| **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 | |
|
| **Pico** | ⭐ Dễ | Kênh giao thức bản địa PicoClaw | |
|
||||||
|
|
||||||
|
|
@ -696,3 +697,67 @@ picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
## 💬 聊天应用集成 (Chat Apps)
|
## 💬 聊天应用集成 (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 服务器来接收入站消息。
|
> **注意**: 依赖 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) |
|
| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](../channels/feishu/README.zh.md) |
|
||||||
| **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | [查看文档](#irc) |
|
| **IRC** | ⭐⭐ 中等 | 服务器 + TLS 配置 | [查看文档](#irc) |
|
||||||
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](../channels/onebot/README.zh.md) |
|
| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](../channels/onebot/README.zh.md) |
|
||||||
|
| **MQTT** | ⭐ 简单 | 任意 MQTT 客户端通过 Broker 收发消息 | [查看文档](../channels/mqtt/README.zh.md) |
|
||||||
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](../channels/maixcam/README.zh.md) |
|
| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](../channels/maixcam/README.zh.md) |
|
||||||
| **Pico** | ⭐ 简单 | PicoClaw 原生协议通道 | |
|
| **Pico** | ⭐ 简单 | PicoClaw 原生协议通道 | |
|
||||||
|
|
||||||
|
|
@ -610,3 +611,69 @@ picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</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>
|
||||||
|
|
|
||||||
281
docs/guides/configuration.it.md
Normal file
281
docs/guides/configuration.it.md
Normal 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
|
||||||
|
```
|
||||||
|
|
@ -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.
|
> **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
|
### 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`.
|
**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).
|
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
|
### 🔒 Security Sandbox
|
||||||
|
|
||||||
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
|
PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace.
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,36 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
|
||||||
|
|
||||||
> **提示:** 对 `AGENT.md`、`SOUL.md`、`USER.md` 和 `memory/MEMORY.md` 的修改会通过文件修改时间(mtime)在运行时自动检测。**无需重启 gateway**,Agent 将在下一次请求时自动加载最新内容。
|
> **提示:** 对 `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 启动器控制台
|
### Web 启动器控制台
|
||||||
|
|
||||||
用 **picoclaw-launcher** 打开浏览器控制台前需要先使用密码登录。首次启动时打开 `/launcher-setup` 创建 dashboard 登录密码;后续手动登录使用 `/launcher-login`。
|
用 **picoclaw-launcher** 打开浏览器控制台前需要先使用密码登录。首次启动时打开 `/launcher-setup` 创建 dashboard 登录密码;后续手动登录使用 `/launcher-login`。
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,14 @@
|
||||||
|
|
||||||
## 📢 Actualités
|
## 📢 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-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** !
|
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 |
|
| Moteur de recherche | Clé API | Niveau gratuit | Lien |
|
||||||
|--------------------|---------|----------------|------|
|
|--------------------|---------|----------------|------|
|
||||||
| DuckDuckGo | Non requise | Illimité | Fallback intégré |
|
| 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 |
|
| [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é |
|
| [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 |
|
| [Perplexity](https://www.perplexity.ai) | Requise | Payant | Recherche propulsée par IA |
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,14 @@
|
||||||
|
|
||||||
## 📢 Berita
|
## 📢 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-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**!
|
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 |
|
| Mesin Pencari | API Key | Tier Gratis | Tautan |
|
||||||
|--------------|---------|-------------|--------|
|
|--------------|---------|-------------|--------|
|
||||||
| DuckDuckGo | Tidak perlu | Tidak terbatas | Fallback bawaan |
|
| 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 |
|
| [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 |
|
| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 kueri/bulan | Cepat dan privat |
|
||||||
| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Pencarian bertenaga AI |
|
| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Pencarian bertenaga AI |
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,14 @@
|
||||||
|
|
||||||
## 📢 Novità
|
## 📢 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-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**!
|
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 |
|
| Motore di Ricerca | API Key | Piano Gratuito | Link |
|
||||||
|-------------------|---------|----------------|------|
|
|-------------------|---------|----------------|------|
|
||||||
| DuckDuckGo | Non necessaria | Illimitato | Fallback integrato |
|
| 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 |
|
| [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 |
|
| [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 |
|
| [Perplexity](https://www.perplexity.ai) | Richiesta | A pagamento | Ricerca potenziata dall'IA |
|
||||||
|
|
|
||||||
|
|
@ -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-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 ⭐** 達成!
|
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 キー | 無料枠 | リンク |
|
| 検索エンジン | API キー | 無料枠 | リンク |
|
||||||
|------------|---------|--------|-------|
|
|------------|---------|--------|-------|
|
||||||
| DuckDuckGo | 不要 | 無制限 | 内蔵フォールバック |
|
| 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 向けに最適化 |
|
| [Tavily](https://tavily.com) | 必須 | 1000 クエリ/月 | AI Agent 向けに最適化 |
|
||||||
| [Brave Search](https://brave.com/search/api) | 必須 | 2000 クエリ/月 | 高速でプライベート |
|
| [Brave Search](https://brave.com/search/api) | 必須 | 2000 クエリ/月 | 高速でプライベート |
|
||||||
| [Perplexity](https://www.perplexity.ai) | 必須 | 有料 | AI 搭載検索 |
|
| [Perplexity](https://www.perplexity.ai) | 必須 | 有料 | AI 搭載検索 |
|
||||||
|
|
|
||||||
|
|
@ -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-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 스타**를 달성했습니다!
|
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 | 무료 제공량 | 링크 |
|
| 검색 엔진 | API Key | 무료 제공량 | 링크 |
|
||||||
|-----------|---------|-------------|------|
|
|-----------|---------|-------------|------|
|
||||||
| DuckDuckGo | 불필요 | 무제한 | 내장 백업 검색 |
|
| 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 에이전트에 최적화 |
|
| [Tavily](https://tavily.com) | 필수 | 월 1000회 쿼리 | AI 에이전트에 최적화 |
|
||||||
| [Brave Search](https://brave.com/search/api) | 필수 | 월 2000회 쿼리 | 빠르고 프라이빗함 |
|
| [Brave Search](https://brave.com/search/api) | 필수 | 월 2000회 쿼리 | 빠르고 프라이빗함 |
|
||||||
| [Perplexity](https://www.perplexity.ai) | 필수 | 유료 | AI 기반 검색 |
|
| [Perplexity](https://www.perplexity.ai) | 필수 | 유료 | AI 기반 검색 |
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,14 @@
|
||||||
|
|
||||||
## 📢 Berita
|
## 📢 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-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**!
|
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 |
|
| Enjin Carian | Kunci API | Peringkat Percuma | Pautan |
|
||||||
|-------------|-----------|-------------------|--------|
|
|-------------|-----------|-------------------|--------|
|
||||||
| DuckDuckGo | Tidak perlu | Tanpa had | Sandaran terbina dalam |
|
| 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 |
|
| [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 |
|
| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 pertanyaan/bulan | Pantas dan peribadi |
|
||||||
| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Carian dikuasai AI |
|
| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Carian dikuasai AI |
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,14 @@
|
||||||
|
|
||||||
## 📢 Novidades
|
## 📢 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-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**!
|
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 |
|
| Motor de Busca | API Key | Nível Gratuito | Link |
|
||||||
|----------------|---------|----------------|------|
|
|----------------|---------|----------------|------|
|
||||||
| DuckDuckGo | Não necessária | Ilimitado | Fallback integrado |
|
| 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 |
|
| [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 |
|
| [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 |
|
| [Perplexity](https://www.perplexity.ai) | Obrigatória | Pago | Busca com IA |
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,14 @@
|
||||||
|
|
||||||
## 📢 Tin tức
|
## 📢 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-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**!
|
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 |
|
| 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 |
|
| 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 |
|
| [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ư |
|
| [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 |
|
| [Perplexity](https://www.perplexity.ai) | Bắt buộc | Trả phí | Tìm kiếm hỗ trợ AI |
|
||||||
|
|
|
||||||
|
|
@ -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-31 📱 **Android 支持!** PicoClaw 现可在 Android 上运行!APK 下载地址:[picoclaw.io](https://picoclaw.io/download)
|
||||||
|
|
||||||
2026-03-25 🚀 **v0.2.4 发布!** Agent 架构全面重构(SubTurn、Hook、Steering、EventBus)、微信/企业微信深度集成、安全体系升级(.security.yml、敏感数据过滤)、新增 Provider(AWS Bedrock、Azure、小米 MiMo),以及 35 项 Bug 修复。PicoClaw 已达 **26K ⭐**!
|
2026-03-25 🚀 **v0.2.4 发布!** Agent 架构全面重构(SubTurn、Hook、Steering、EventBus)、微信/企业微信深度集成、安全体系升级(.security.yml、敏感数据过滤)、新增 Provider(AWS Bedrock、Azure、小米 MiMo),以及 35 项 Bug 修复。PicoClaw 已达 **26K ⭐**!
|
||||||
|
|
@ -144,9 +152,9 @@ _*近期版本因快速合并 PR 可能占用 10–20MB,资源优化已列入
|
||||||
|
|
||||||
PicoClaw 几乎可以部署在任何 Linux 设备上!
|
PicoClaw 几乎可以部署在任何 Linux 设备上!
|
||||||
|
|
||||||
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手
|
- $9.9 [LicheeRV-Nano](https://item.taobao.com/item.htm?id=764939520376) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手
|
||||||
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维
|
- $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://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控
|
- $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>
|
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
|
||||||
|
|
||||||
|
|
@ -475,7 +483,7 @@ PicoClaw 可以搜索网络以提供最新信息。在 `tools.web` 中配置:
|
||||||
|
|
||||||
| 搜索引擎 | API Key | 免费额度 | 链接 |
|
| 搜索引擎 | 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 优化 |
|
| [Tavily](https://tavily.com) | 必填 | 1000 次/月 | 专为 AI Agent 优化 |
|
||||||
| [GLM Search](https://open.bigmodel.cn/) | 必填 | 视情况 | 智谱网络搜索 |
|
| [GLM Search](https://open.bigmodel.cn/) | 必填 | 视情况 | 智谱网络搜索 |
|
||||||
| DuckDuckGo | 无需 | 无限制 | 内置备用(国内访问困难) |
|
| DuckDuckGo | 无需 | 无限制 | 内置备用(国内访问困难) |
|
||||||
|
|
|
||||||
|
|
@ -282,4 +282,3 @@ New config (version 3):
|
||||||
- Check that the migration doesn't overwrite values with defaults unnecessarily
|
- Check that the migration doesn't overwrite values with defaults unnecessarily
|
||||||
- Review the conversion logic in the loader functions
|
- Review the conversion logic in the loader functions
|
||||||
- Check the auto-backup files (e.g., `config.json.20260330.bak`) to recover original data
|
- Check the auto-backup files (e.g., `config.json.20260330.bak`) to recover original data
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,13 @@ channels:
|
||||||
nickserv_password: "your-irc-nickserv-password"
|
nickserv_password: "your-irc-nickserv-password"
|
||||||
sasl_password: "your-irc-sasl-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 Tool API Keys
|
||||||
web:
|
web:
|
||||||
brave:
|
brave:
|
||||||
|
|
@ -226,6 +233,19 @@ channels:
|
||||||
- `channels.feishu.app_secret` → `config.channels.feishu.app_secret`
|
- `channels.feishu.app_secret` → `config.channels.feishu.app_secret`
|
||||||
- etc.
|
- 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
|
### Web Tools
|
||||||
|
|
||||||
**Brave, Tavily, Perplexity:**
|
**Brave, Tavily, Perplexity:**
|
||||||
|
|
|
||||||
10
go.mod
10
go.mod
|
|
@ -1,9 +1,9 @@
|
||||||
module github.com/sipeed/picoclaw
|
module github.com/sipeed/picoclaw
|
||||||
|
|
||||||
go 1.25.9
|
go 1.25.10
|
||||||
|
|
||||||
require (
|
require (
|
||||||
fyne.io/systray v1.12.0
|
fyne.io/systray v1.12.1
|
||||||
github.com/SevereCloud/vksdk/v3 v3.3.1
|
github.com/SevereCloud/vksdk/v3 v3.3.1
|
||||||
github.com/adhocore/gronx v1.19.6
|
github.com/adhocore/gronx v1.19.6
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||||
|
|
@ -21,7 +21,8 @@ require (
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/h2non/filetype v1.1.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/mdp/qrterminal/v3 v3.2.1
|
||||||
github.com/minio/selfupdate v0.6.0
|
github.com/minio/selfupdate v0.6.0
|
||||||
github.com/modelcontextprotocol/go-sdk v1.5.0
|
github.com/modelcontextprotocol/go-sdk v1.5.0
|
||||||
|
|
@ -75,6 +76,7 @@ require (
|
||||||
github.com/coder/websocket v1.8.14 // indirect
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.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/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // 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/github/copilot-sdk/go v0.2.0
|
||||||
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
github.com/go-resty/resty/v2 v2.17.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // 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/grbit/go-json v0.11.0 // indirect
|
||||||
github.com/klauspost/compress v1.18.4 // indirect
|
github.com/klauspost/compress v1.18.4 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
|
|
||||||
16
go.sum
16
go.sum
|
|
@ -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=
|
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 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
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.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ=
|
||||||
fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
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 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
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=
|
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/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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
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 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||||
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||||
github.com/ergochat/irc-go v0.6.0 h1:Y0AGV76aeihJfCtLaQh+OyJKFiKGrYC0VTkeMZ6XW28=
|
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.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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
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=
|
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.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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.6.1 h1:vAdu+sX9yXNkKnKnYQeIv6yBkjP37Q1JEJHmMa2eCjQ=
|
||||||
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/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 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
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=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
|
|
||||||
|
|
@ -55,9 +55,11 @@ type AgentLoop struct {
|
||||||
transcriber asr.Transcriber
|
transcriber asr.Transcriber
|
||||||
cmdRegistry *commands.Registry
|
cmdRegistry *commands.Registry
|
||||||
mcp mcpRuntime
|
mcp mcpRuntime
|
||||||
|
evolution *evolutionBridge
|
||||||
hookRuntime hookRuntime
|
hookRuntime hookRuntime
|
||||||
steering *steeringQueue
|
steering *steeringQueue
|
||||||
pendingSkills sync.Map
|
pendingSkills sync.Map
|
||||||
|
pendingStops sync.Map
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
// workerSem limits concurrent turn processing workers.
|
// workerSem limits concurrent turn processing workers.
|
||||||
|
|
@ -177,6 +179,12 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
phase: TurnPhaseSetup,
|
phase: TurnPhaseSetup,
|
||||||
}
|
}
|
||||||
if _, loaded := al.activeTurnStates.LoadOrStore(sessionKey, placeholder); loaded {
|
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
|
// Another turn is already active (or reserved) for this session — enqueue
|
||||||
if err := al.enqueueSteeringMessage(sessionKey, agentID, providers.Message{
|
if err := al.enqueueSteeringMessage(sessionKey, agentID, providers.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
|
|
@ -240,6 +248,24 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
||||||
defer al.channelManager.InvokeTypingStop(m.Channel, m.ChatID)
|
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)
|
al.runTurnWithSteering(ctx, m)
|
||||||
}(msg)
|
}(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()
|
al.GetRegistry().Close()
|
||||||
if al.hooks != nil {
|
if al.hooks != nil {
|
||||||
|
|
@ -369,14 +404,29 @@ func (al *AgentLoop) ReloadProviderAndConfig(
|
||||||
// Ensure shared tools are re-registered on the new registry
|
// Ensure shared tools are re-registered on the new registry
|
||||||
registerSharedTools(al, cfg, al.bus, registry, provider)
|
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
|
// Atomically swap the config and registry under write lock
|
||||||
// This ensures readers see a consistent pair
|
// This ensures readers see a consistent pair
|
||||||
al.mu.Lock()
|
al.mu.Lock()
|
||||||
oldRegistry := al.registry
|
oldRegistry := al.registry
|
||||||
|
oldEvolution := al.evolution
|
||||||
|
|
||||||
// Store new values
|
// Store new values
|
||||||
al.cfg = cfg
|
al.cfg = cfg
|
||||||
al.registry = registry
|
al.registry = registry
|
||||||
|
al.evolution = newEvolution
|
||||||
|
|
||||||
// Also update fallback chain with new config; rebuild rate limiter registry.
|
// Also update fallback chain with new config; rebuild rate limiter registry.
|
||||||
newRL := providers.NewRateLimiterRegistry()
|
newRL := providers.NewRateLimiterRegistry()
|
||||||
|
|
@ -404,6 +454,12 @@ func (al *AgentLoop) ReloadProviderAndConfig(
|
||||||
map[string]any{"error": err.Error()})
|
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 {
|
if err := al.ensureMCPInitialized(ctx); err != nil {
|
||||||
logger.WarnCF("agent", "MCP failed to reinitialize after reload",
|
logger.WarnCF("agent", "MCP failed to reinitialize after reload",
|
||||||
map[string]any{"error": err.Error()})
|
map[string]any{"error": err.Error()})
|
||||||
|
|
|
||||||
|
|
@ -274,6 +274,12 @@ func (al *AgentLoop) buildCommandsRuntime(
|
||||||
return nil
|
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 {
|
if agent != nil && agent.ContextBuilder != nil {
|
||||||
rt.ListSkillNames = agent.ContextBuilder.ListSkillNames
|
rt.ListSkillNames = agent.ContextBuilder.ListSkillNames
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,40 @@ func (al *AgentLoop) emitEvent(kind runtimeevents.Kind, meta HookMeta, payload a
|
||||||
return
|
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)
|
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.
|
// MountHook registers an in-process hook on the agent loop.
|
||||||
func (al *AgentLoop) MountHook(reg HookRegistration) error {
|
func (al *AgentLoop) MountHook(reg HookRegistration) error {
|
||||||
if al == nil || al.hooks == nil {
|
if al == nil || al.hooks == nil {
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,13 @@ func NewAgentLoop(
|
||||||
stateManager = state.NewManager(defaultAgent.Workspace)
|
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)
|
// Determine worker pool size from config (default: 1 = sequential)
|
||||||
workerPoolSize := cfg.Agents.Defaults.MaxParallelTurns
|
workerPoolSize := cfg.Agents.Defaults.MaxParallelTurns
|
||||||
if workerPoolSize <= 0 {
|
if workerPoolSize <= 0 {
|
||||||
|
|
@ -62,6 +69,7 @@ func NewAgentLoop(
|
||||||
state: stateManager,
|
state: stateManager,
|
||||||
fallback: fallbackChain,
|
fallback: fallbackChain,
|
||||||
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
|
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
|
||||||
|
evolution: bridge,
|
||||||
steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)),
|
steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)),
|
||||||
workerSem: make(chan struct{}, workerPoolSize),
|
workerSem: make(chan struct{}, workerPoolSize),
|
||||||
ownsRuntimeEvents: true,
|
ownsRuntimeEvents: true,
|
||||||
|
|
@ -75,6 +83,14 @@ func NewAgentLoop(
|
||||||
al.runtimeEvents = runtimeevents.NewBus()
|
al.runtimeEvents = runtimeevents.NewBus()
|
||||||
al.ownsRuntimeEvents = true
|
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.refreshRuntimeEventLogger(cfg)
|
||||||
al.providerFactory = providers.CreateProviderFromConfig
|
al.providerFactory = providers.CreateProviderFromConfig
|
||||||
al.hooks = NewHookManager(al.runtimeEvents.Channel())
|
al.hooks = NewHookManager(al.runtimeEvents.Channel())
|
||||||
|
|
@ -337,5 +353,22 @@ func registerSharedTools(
|
||||||
} else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") {
|
} else if (spawnEnabled || spawnStatusEnabled) && !cfg.Tools.IsToolEnabled("subagent") {
|
||||||
logger.WarnCF("agent", "spawn/spawn_status tools require subagent to be enabled", nil)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,8 +85,18 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
||||||
return nil
|
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
|
findValidServer := false
|
||||||
for _, serverCfg := range al.cfg.Tools.MCP.Servers {
|
for _, serverCfg := range mcpCfg.Servers {
|
||||||
if serverCfg.Enabled {
|
if serverCfg.Enabled {
|
||||||
findValidServer = true
|
findValidServer = true
|
||||||
}
|
}
|
||||||
|
|
@ -105,7 +115,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
||||||
workspacePath = defaultAgent.Workspace
|
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))
|
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",
|
logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available",
|
||||||
map[string]any{
|
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).
|
// Determine whether this server's tools should be deferred (hidden).
|
||||||
// Per-server "deferred" field takes precedence over the global Discovery.Enabled.
|
// 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)
|
registerAsHidden := serverIsDeferred(al.cfg.Tools.MCP.Discovery.Enabled, serverCfg)
|
||||||
|
registeredToolsByAgent := make(map[string]map[string]struct{}, len(agentIDs))
|
||||||
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(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tool := range conn.Tools {
|
for _, tool := range conn.Tools {
|
||||||
for _, agentID := range agentIDs {
|
for _, agentID := range agentIDs {
|
||||||
|
|
@ -160,8 +152,18 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
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)
|
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
|
||||||
|
toolName := mcpTool.Name()
|
||||||
mcpTool.SetWorkspace(agent.Workspace)
|
mcpTool.SetWorkspace(agent.Workspace)
|
||||||
mcpTool.SetMaxInlineTextRunes(al.cfg.Tools.MCP.GetMaxInlineTextChars())
|
mcpTool.SetMaxInlineTextRunes(al.cfg.Tools.MCP.GetMaxInlineTextChars())
|
||||||
mcpTool.SetEventPublisher(al.runtimeEvents)
|
mcpTool.SetEventPublisher(al.runtimeEvents)
|
||||||
|
|
@ -171,18 +173,36 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
||||||
} else {
|
} else {
|
||||||
agent.Tools.Register(mcpTool)
|
agent.Tools.Register(mcpTool)
|
||||||
}
|
}
|
||||||
|
if !toolRegistryIncludes(agent.Tools, toolName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
recordRegisteredMCPTool(registeredToolsByAgent, agentID, toolName)
|
||||||
totalRegistrations++
|
totalRegistrations++
|
||||||
logger.DebugCF("agent", "Registered MCP tool",
|
logger.DebugCF("agent", "Registered MCP tool",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"agent_id": agentID,
|
"agent_id": agentID,
|
||||||
"server": serverName,
|
"server": serverName,
|
||||||
"tool": tool.Name,
|
"tool": tool.Name,
|
||||||
"name": mcpTool.Name(),
|
"name": toolName,
|
||||||
"deferred": registerAsHidden,
|
"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",
|
logger.InfoCF("agent", "MCP tools registered successfully",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
|
|
@ -230,6 +250,9 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if !agentHasDiscoverableMCPServers(al.cfg, agent.MCPServerAllowlist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if useRegex {
|
if useRegex {
|
||||||
agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults))
|
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()
|
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
|
// serverIsDeferred reports whether an MCP server's tools should be registered
|
||||||
// as hidden (deferred/discovery mode).
|
// as hidden (deferred/discovery mode).
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import (
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/mcp"
|
"github.com/sipeed/picoclaw/pkg/mcp"
|
||||||
|
agenttools "github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
func boolPtr(b bool) *bool { return &b }
|
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) {
|
func TestEnsureMCPInitialized_LoadFailureSetsInitErr(t *testing.T) {
|
||||||
al, cfg, _, _, cleanup := newTestAgentLoop(t)
|
al, cfg, _, _, cleanup := newTestAgentLoop(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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)
|
// Add message preview to log (show full content for error messages)
|
||||||
var logContent string
|
var logContent string
|
||||||
if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") {
|
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
|
// Route system messages to processSystemMessage
|
||||||
if msg.Channel == "system" {
|
if msg.Channel == "system" {
|
||||||
return al.processSystemMessage(ctx, msg)
|
return al.processSystemMessage(ctx, msg)
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,16 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
|
||||||
}
|
}
|
||||||
|
|
||||||
if alreadySentToSameChat {
|
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(
|
logger.DebugCF(
|
||||||
"agent",
|
"agent",
|
||||||
"Skipped outbound (message tool already sent to same chat)",
|
"Skipped outbound (message tool already sent to same chat)",
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,36 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb
|
||||||
return
|
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 {
|
for al.pendingSteeringCountForScope(target.SessionKey) > 0 {
|
||||||
// Check for context cancellation between iterations
|
if err := ctx.Err(); err != nil {
|
||||||
if ctx.Err() != nil {
|
return finalResponse, err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.InfoCF("agent", "Continuing queued steering after turn end",
|
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)
|
continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID)
|
||||||
if continueErr != nil {
|
if continueErr != nil {
|
||||||
logger.WarnCF("agent", "Failed to continue queued steering",
|
return finalResponse, continueErr
|
||||||
map[string]any{
|
|
||||||
"channel": target.Channel,
|
|
||||||
"chat_id": target.ChatID,
|
|
||||||
"error": continueErr.Error(),
|
|
||||||
})
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
if continued == "" {
|
if continued == "" {
|
||||||
break
|
break
|
||||||
|
|
@ -75,10 +94,7 @@ func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.Inb
|
||||||
finalResponse = continued
|
finalResponse = continued
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish final response
|
return finalResponse, nil
|
||||||
if finalResponse != "" {
|
|
||||||
al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, string, bool) {
|
func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, string, bool) {
|
||||||
|
|
|
||||||
122
pkg/agent/agent_stop.go
Normal file
122
pkg/agent/agent_stop.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -57,6 +57,38 @@ func (f *fakeMediaChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaM
|
||||||
return nil, nil
|
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(
|
func newStartedTestChannelManager(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
msgBus *bus.MessageBus,
|
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) {
|
func TestProcessMessage_IncludesCurrentSenderInDynamicContext(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ type ContextBuilder struct {
|
||||||
memory *MemoryStore
|
memory *MemoryStore
|
||||||
splitOnMarker bool
|
splitOnMarker bool
|
||||||
skillCatalogCfg config.SkillCatalogConfig
|
skillCatalogCfg config.SkillCatalogConfig
|
||||||
|
agentDiscovery func(agentID string) []AgentDescriptor
|
||||||
promptRegistry *PromptRegistry
|
promptRegistry *PromptRegistry
|
||||||
|
|
||||||
// Cache for system prompt to avoid rebuilding on every call.
|
// Cache for system prompt to avoid rebuilding on every call.
|
||||||
|
|
@ -72,6 +73,24 @@ func (cb *ContextBuilder) WithSkillCatalogConfig(cfg config.SkillCatalogConfig)
|
||||||
return cb
|
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 {
|
func getGlobalConfigDir() string {
|
||||||
return config.GetHome()
|
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)")
|
now := time.Now().Format("2006-01-02 15:04 (Monday)")
|
||||||
rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
|
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":
|
case "assistant":
|
||||||
if len(msg.ToolCalls) > 0 {
|
if len(msg.ToolCalls) > 0 {
|
||||||
if len(sanitized) == 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
|
continue
|
||||||
}
|
}
|
||||||
prev := sanitized[len(sanitized)-1]
|
prev := sanitized[len(sanitized)-1]
|
||||||
|
|
@ -1024,10 +1049,28 @@ func (cb *ContextBuilder) AddAssistantMessage(
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) buildActiveSkillsContext(skillNames []string) string {
|
func (cb *ContextBuilder) buildActiveSkillsContext(skillNames []string) string {
|
||||||
if cb.skillsLoader == nil || len(skillNames) == 0 {
|
ordered := cb.ResolveActiveSkillsForContext(skillNames)
|
||||||
|
if len(ordered) == 0 {
|
||||||
return ""
|
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
|
var ordered []string
|
||||||
seen := make(map[string]struct{}, len(skillNames))
|
seen := make(map[string]struct{}, len(skillNames))
|
||||||
for _, name := range skillNames {
|
for _, name := range skillNames {
|
||||||
|
|
@ -1042,19 +1085,9 @@ func (cb *ContextBuilder) buildActiveSkillsContext(skillNames []string) string {
|
||||||
ordered = append(ordered, canonical)
|
ordered = append(ordered, canonical)
|
||||||
}
|
}
|
||||||
if len(ordered) == 0 {
|
if len(ordered) == 0 {
|
||||||
return ""
|
return nil
|
||||||
}
|
}
|
||||||
|
return ordered
|
||||||
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) buildActiveSkillsPromptParts(skillNames []string) []PromptPart {
|
func (cb *ContextBuilder) buildActiveSkillsPromptParts(skillNames []string) []PromptPart {
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ type AgentFrontmatter struct {
|
||||||
MaxTurns *int `json:"maxTurns,omitempty"`
|
MaxTurns *int `json:"maxTurns,omitempty"`
|
||||||
Skills []string `json:"skills,omitempty"`
|
Skills []string `json:"skills,omitempty"`
|
||||||
MCPServers []string `json:"mcpServers,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.
|
// AgentPromptDefinition represents the parsed AGENT.md or AGENTS.md prompt file.
|
||||||
|
|
@ -45,6 +45,7 @@ type AgentPromptDefinition struct {
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
RawFrontmatter string `json:"raw_frontmatter,omitempty"`
|
RawFrontmatter string `json:"raw_frontmatter,omitempty"`
|
||||||
Frontmatter AgentFrontmatter `json:"frontmatter"`
|
Frontmatter AgentFrontmatter `json:"frontmatter"`
|
||||||
|
FrontmatterErr string `json:"frontmatter_error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SoulDefinition represents the resolved SOUL.md file linked to the agent.
|
// 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 {
|
func parseAgentPromptDefinition(path, content string) AgentPromptDefinition {
|
||||||
frontmatter, body := splitAgentFrontmatter(content)
|
frontmatter, body := splitAgentFrontmatter(content)
|
||||||
|
parsedFrontmatter, err := parseAgentFrontmatter(path, frontmatter)
|
||||||
return AgentPromptDefinition{
|
return AgentPromptDefinition{
|
||||||
Path: path,
|
Path: path,
|
||||||
Raw: content,
|
Raw: content,
|
||||||
Body: body,
|
Body: body,
|
||||||
RawFrontmatter: frontmatter,
|
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)
|
frontmatter = strings.TrimSpace(frontmatter)
|
||||||
if frontmatter == "" {
|
if frontmatter == "" {
|
||||||
return AgentFrontmatter{}
|
return AgentFrontmatter{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
rawFields := make(map[string]any)
|
rawFields := make(map[string]any)
|
||||||
|
|
@ -167,7 +170,7 @@ func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter {
|
||||||
"path": path,
|
"path": path,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return AgentFrontmatter{}
|
return AgentFrontmatter{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var typed struct {
|
var typed struct {
|
||||||
|
|
@ -184,7 +187,7 @@ func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter {
|
||||||
"path": path,
|
"path": path,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
return AgentFrontmatter{}
|
return AgentFrontmatter{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return AgentFrontmatter{
|
return AgentFrontmatter{
|
||||||
|
|
@ -196,7 +199,7 @@ func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter {
|
||||||
Skills: append([]string(nil), typed.Skills...),
|
Skills: append([]string(nil), typed.Skills...),
|
||||||
MCPServers: append([]string(nil), typed.MCPServers...),
|
MCPServers: append([]string(nil), typed.MCPServers...),
|
||||||
Fields: rawFields,
|
Fields: rawFields,
|
||||||
}
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func splitAgentFrontmatter(content string) (frontmatter, body string) {
|
func splitAgentFrontmatter(content string) (frontmatter, body string) {
|
||||||
|
|
@ -253,3 +256,10 @@ func fileExists(path string) bool {
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func errorString(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
|
||||||
263
pkg/agent/discovery.go
Normal file
263
pkg/agent/discovery.go
Normal 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
420
pkg/agent/discovery_test.go
Normal 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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,12 +20,39 @@ type TurnStartPayload struct {
|
||||||
MediaCount int
|
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.
|
// TurnEndPayload describes the completion of a turn.
|
||||||
type TurnEndPayload struct {
|
type TurnEndPayload struct {
|
||||||
Status TurnEndStatus
|
Status TurnEndStatus
|
||||||
|
Workspace string
|
||||||
Iterations int
|
Iterations int
|
||||||
Duration time.Duration
|
Duration time.Duration
|
||||||
FinalContentLen int
|
FinalContentLen int
|
||||||
|
UserMessage string
|
||||||
|
FinalContent string
|
||||||
|
ActiveSkills []string
|
||||||
|
AttemptedSkills []string
|
||||||
|
FinalSuccessfulPath []string
|
||||||
|
SkillContextSnapshots []SkillContextSnapshot
|
||||||
|
ToolKinds []string
|
||||||
|
ToolExecutions []ToolExecutionRecord
|
||||||
}
|
}
|
||||||
|
|
||||||
// LLMRequestPayload describes an outbound LLM request.
|
// LLMRequestPayload describes an outbound LLM request.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,11 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||||
|
)
|
||||||
|
|
||||||
// HookMeta contains correlation fields shared by agent hook requests and
|
// HookMeta contains correlation fields shared by agent hook requests and
|
||||||
// runtime events emitted from turn processing.
|
// runtime events emitted from turn processing.
|
||||||
type HookMeta struct {
|
type HookMeta struct {
|
||||||
|
|
@ -12,3 +18,42 @@ type HookMeta struct {
|
||||||
Source string
|
Source string
|
||||||
turnContext *TurnContext
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
444
pkg/agent/evolution_bridge.go
Normal file
444
pkg/agent/evolution_bridge.go
Normal 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
|
||||||
|
}
|
||||||
1344
pkg/agent/evolution_bridge_test.go
Normal file
1344
pkg/agent/evolution_bridge_test.go
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -38,8 +38,10 @@ type AgentInstance struct {
|
||||||
Sessions session.SessionStore
|
Sessions session.SessionStore
|
||||||
ContextBuilder *ContextBuilder
|
ContextBuilder *ContextBuilder
|
||||||
Tools *tools.ToolRegistry
|
Tools *tools.ToolRegistry
|
||||||
|
Definition AgentContextDefinition
|
||||||
Subagents *config.SubagentsConfig
|
Subagents *config.SubagentsConfig
|
||||||
SkillsFilter []string
|
SkillsFilter []string
|
||||||
|
MCPServerAllowlist map[string]struct{}
|
||||||
Candidates []providers.FallbackCandidate
|
Candidates []providers.FallbackCandidate
|
||||||
|
|
||||||
// Router is non-nil when model routing is configured and the light model
|
// Router is non-nil when model routing is configured and the light model
|
||||||
|
|
@ -74,7 +76,9 @@ func NewAgentInstance(
|
||||||
workspace := resolveAgentWorkspace(agentCfg, defaults)
|
workspace := resolveAgentWorkspace(agentCfg, defaults)
|
||||||
os.MkdirAll(workspace, 0o755)
|
os.MkdirAll(workspace, 0o755)
|
||||||
|
|
||||||
model := resolveAgentModel(agentCfg, defaults)
|
definition := loadAgentDefinition(workspace)
|
||||||
|
|
||||||
|
model := resolveAgentModel(agentCfg, defaults, definition)
|
||||||
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
|
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
|
||||||
|
|
||||||
restrict := defaults.RestrictToWorkspace
|
restrict := defaults.RestrictToWorkspace
|
||||||
|
|
@ -83,8 +87,11 @@ func NewAgentInstance(
|
||||||
// Compile path whitelist patterns from config.
|
// Compile path whitelist patterns from config.
|
||||||
allowReadPaths := buildAllowReadPatterns(cfg)
|
allowReadPaths := buildAllowReadPatterns(cfg)
|
||||||
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
|
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
|
||||||
|
agentToolAllowlist := resolveAgentToolAllowlist(definition)
|
||||||
|
agentMCPServerAllowlist := resolveAgentMCPServerAllowlist(definition)
|
||||||
|
|
||||||
toolsRegistry := tools.NewToolRegistry()
|
toolsRegistry := tools.NewToolRegistry()
|
||||||
|
toolsRegistry.SetAllowlist(agentToolAllowlist)
|
||||||
|
|
||||||
if cfg.Tools.IsToolEnabled("read_file") {
|
if cfg.Tools.IsToolEnabled("read_file") {
|
||||||
maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize
|
maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize
|
||||||
|
|
@ -121,7 +128,7 @@ func NewAgentInstance(
|
||||||
sessionsDir := filepath.Join(workspace, "sessions")
|
sessionsDir := filepath.Join(workspace, "sessions")
|
||||||
sessions := initSessionStore(sessionsDir)
|
sessions := initSessionStore(sessionsDir)
|
||||||
|
|
||||||
mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled
|
mcpDiscoveryActive := agentHasDiscoverableMCPServers(cfg, agentMCPServerAllowlist)
|
||||||
contextBuilder := NewContextBuilder(workspace).
|
contextBuilder := NewContextBuilder(workspace).
|
||||||
WithToolDiscovery(
|
WithToolDiscovery(
|
||||||
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
|
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
|
||||||
|
|
@ -138,9 +145,14 @@ func NewAgentInstance(
|
||||||
if agentCfg != nil {
|
if agentCfg != nil {
|
||||||
agentID = routing.NormalizeAgentID(agentCfg.ID)
|
agentID = routing.NormalizeAgentID(agentCfg.ID)
|
||||||
agentName = agentCfg.Name
|
agentName = agentCfg.Name
|
||||||
subagents = agentCfg.Subagents
|
if definition.Agent != nil && strings.TrimSpace(definition.Agent.Frontmatter.Name) != "" {
|
||||||
skillsFilter = agentCfg.Skills
|
agentName = strings.TrimSpace(definition.Agent.Frontmatter.Name)
|
||||||
}
|
}
|
||||||
|
subagents = agentCfg.Subagents
|
||||||
|
skillsFilter = resolveAgentSkillsFilter(agentCfg, definition)
|
||||||
|
}
|
||||||
|
provider = resolvePrimaryProviderForAgent(cfg, workspace, agentID, model, provider)
|
||||||
|
warnOnUnknownAgentMCPServerDeclarations(agentID, workspace, cfg, definition)
|
||||||
|
|
||||||
maxIter := defaults.MaxToolIterations
|
maxIter := defaults.MaxToolIterations
|
||||||
if maxIter == 0 {
|
if maxIter == 0 {
|
||||||
|
|
@ -200,8 +212,15 @@ func NewAgentInstance(
|
||||||
if len(resolved) > 0 {
|
if len(resolved) > 0 {
|
||||||
lightModelCfg, err := resolvedModelConfig(cfg, rc.LightModel, workspace)
|
lightModelCfg, err := resolvedModelConfig(cfg, rc.LightModel, workspace)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.WarnCF("agent", "Routing light model config invalid; routing disabled",
|
logger.WarnCF(
|
||||||
map[string]any{"light_model": rc.LightModel, "agent_id": agentID, "error": err.Error()})
|
"agent",
|
||||||
|
"Routing light model config invalid; routing disabled",
|
||||||
|
map[string]any{
|
||||||
|
"light_model": rc.LightModel,
|
||||||
|
"agent_id": agentID,
|
||||||
|
"error": err.Error(),
|
||||||
|
},
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
lp, _, err := providers.CreateProviderFromConfig(lightModelCfg)
|
lp, _, err := providers.CreateProviderFromConfig(lightModelCfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -240,8 +259,10 @@ func NewAgentInstance(
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
ContextBuilder: contextBuilder,
|
ContextBuilder: contextBuilder,
|
||||||
Tools: toolsRegistry,
|
Tools: toolsRegistry,
|
||||||
|
Definition: definition,
|
||||||
Subagents: subagents,
|
Subagents: subagents,
|
||||||
SkillsFilter: skillsFilter,
|
SkillsFilter: skillsFilter,
|
||||||
|
MCPServerAllowlist: agentMCPServerAllowlist,
|
||||||
Candidates: candidates,
|
Candidates: candidates,
|
||||||
Router: router,
|
Router: router,
|
||||||
LightCandidates: lightCandidates,
|
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.
|
// resolveAgentWorkspace determines the workspace directory for an agent.
|
||||||
func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
|
||||||
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
|
||||||
return expandHome(strings.TrimSpace(agentCfg.Workspace))
|
return expandHome(strings.TrimSpace(agentCfg.Workspace))
|
||||||
}
|
}
|
||||||
// Use the configured default workspace (respects PICOCLAW_HOME)
|
// 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)
|
return expandHome(defaults.Workspace)
|
||||||
}
|
}
|
||||||
// For named agents without explicit workspace, use default workspace with agent ID suffix
|
// 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.
|
// 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) != "" {
|
if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
|
||||||
return 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
|
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 {
|
func compilePatterns(patterns []string) []*regexp.Regexp {
|
||||||
compiled := make([]*regexp.Regexp, 0, len(patterns))
|
compiled := make([]*regexp.Regexp, 0, len(patterns))
|
||||||
for _, p := range patterns {
|
for _, p := range patterns {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/media"
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) {
|
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")
|
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
177
pkg/agent/legacy_events.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
76
pkg/agent/legacy_events_test.go
Normal file
76
pkg/agent/legacy_events_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,9 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
|
@ -17,6 +20,89 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"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,
|
// ExecuteTools executes the tool loop, handling BeforeTool/ApproveTool/AfterTool hooks,
|
||||||
// tool execution with async callbacks, media delivery, and steering injection.
|
// tool execution with async callbacks, media delivery, and steering injection.
|
||||||
// Returns ToolControl indicating what the coordinator should do next:
|
// Returns ToolControl indicating what the coordinator should do next:
|
||||||
|
|
@ -203,6 +289,12 @@ toolLoop:
|
||||||
Async: hookResult.Async,
|
Async: hookResult.Async,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
ts.recordToolExecution(
|
||||||
|
toolName,
|
||||||
|
!hookResult.IsError,
|
||||||
|
toolErrorSummary(hookResult),
|
||||||
|
inferSkillNamesFromToolCall(ts, toolName, toolArgs),
|
||||||
|
)
|
||||||
|
|
||||||
messages = append(messages, toolResultMsg)
|
messages = append(messages, toolResultMsg)
|
||||||
if !ts.opts.NoHistory {
|
if !ts.opts.NoHistory {
|
||||||
|
|
@ -579,6 +671,12 @@ toolLoop:
|
||||||
Async: toolResult.Async,
|
Async: toolResult.Async,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
ts.recordToolExecution(
|
||||||
|
toolName,
|
||||||
|
!toolResult.IsError,
|
||||||
|
toolErrorSummary(toolResult),
|
||||||
|
inferSkillNamesFromToolCall(ts, toolName, toolArgs),
|
||||||
|
)
|
||||||
messages = append(messages, toolResultMsg)
|
messages = append(messages, toolResultMsg)
|
||||||
if !ts.opts.NoHistory {
|
if !ts.opts.NoHistory {
|
||||||
ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg)
|
ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg)
|
||||||
|
|
|
||||||
50
pkg/agent/pipeline_execute_test.go
Normal file
50
pkg/agent/pipeline_execute_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -364,9 +364,14 @@ func (p *Pipeline) CallLLM(
|
||||||
exec.history = asmResp.History
|
exec.history = asmResp.History
|
||||||
exec.summary = asmResp.Summary
|
exec.summary = asmResp.Summary
|
||||||
}
|
}
|
||||||
exec.messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(
|
contextualSkills := ts.activeSkills
|
||||||
promptBuildRequestForTurn(ts, exec.history, exec.summary, "", nil),
|
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
|
exec.callMessages = exec.messages
|
||||||
if exec.gracefulTerminal {
|
if exec.gracefulTerminal {
|
||||||
msgs := append([]providers.Message(nil), exec.messages...)
|
msgs := append([]providers.Message(nil), exec.messages...)
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,14 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
||||||
}
|
}
|
||||||
ts.captureRestorePoint(history, summary)
|
ts.captureRestorePoint(history, summary)
|
||||||
|
|
||||||
messages := ts.agent.ContextBuilder.BuildMessagesFromPrompt(
|
contextualSkills := ts.activeSkills
|
||||||
promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media),
|
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)
|
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
|
||||||
|
|
||||||
|
|
@ -61,9 +66,9 @@ func (p *Pipeline) SetupTurn(ctx context.Context, ts *turnState) (*turnExecution
|
||||||
history = resp.History
|
history = resp.History
|
||||||
summary = resp.Summary
|
summary = resp.Summary
|
||||||
}
|
}
|
||||||
messages = ts.agent.ContextBuilder.BuildMessagesFromPrompt(
|
rebuildPromptReq := promptBuildRequestForTurn(ts, history, summary, ts.userMessage, ts.media)
|
||||||
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)
|
messages = resolveMediaRefs(messages, p.MediaStore, maxMediaSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ const (
|
||||||
PromptSourceMemory PromptSourceID = "memory:workspace"
|
PromptSourceMemory PromptSourceID = "memory:workspace"
|
||||||
PromptSourceSkillCatalog PromptSourceID = "skill:index"
|
PromptSourceSkillCatalog PromptSourceID = "skill:index"
|
||||||
PromptSourceActiveSkills PromptSourceID = "skill:active"
|
PromptSourceActiveSkills PromptSourceID = "skill:active"
|
||||||
|
PromptSourceAgentDiscovery PromptSourceID = "agent:discovery"
|
||||||
PromptSourceToolRegistry PromptSourceID = "tool_registry:native"
|
PromptSourceToolRegistry PromptSourceID = "tool_registry:native"
|
||||||
PromptSourceToolDiscovery PromptSourceID = "tool_registry:discovery"
|
PromptSourceToolDiscovery PromptSourceID = "tool_registry:discovery"
|
||||||
PromptSourceOutputPolicy PromptSourceID = "runtime.output"
|
PromptSourceOutputPolicy PromptSourceID = "runtime.output"
|
||||||
|
|
@ -195,6 +196,13 @@ func builtinPromptSources() []PromptSourceDescriptor {
|
||||||
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotActiveSkill}},
|
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotActiveSkill}},
|
||||||
StableByDefault: false,
|
StableByDefault: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: PromptSourceAgentDiscovery,
|
||||||
|
Owner: "agent",
|
||||||
|
Description: "Structured multi-agent discovery registry",
|
||||||
|
Allowed: []PromptPlacement{{Layer: PromptLayerCapability, Slot: PromptSlotTooling}},
|
||||||
|
StableByDefault: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: PromptSourceMemory,
|
ID: PromptSourceMemory,
|
||||||
Owner: "memory",
|
Owner: "memory",
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,47 @@ func (c mcpServerPromptContributor) ContributePrompt(
|
||||||
}, nil
|
}, 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 {
|
func mcpPromptSourceID(serverName string) PromptSourceID {
|
||||||
return PromptSourceID("mcp:" + promptSourceComponent(serverName))
|
return PromptSourceID("mcp:" + promptSourceComponent(serverName))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
|
|
||||||
// AgentRegistry manages multiple agent instances and routes messages to them.
|
// AgentRegistry manages multiple agent instances and routes messages to them.
|
||||||
type AgentRegistry struct {
|
type AgentRegistry struct {
|
||||||
|
cfg *config.Config
|
||||||
agents map[string]*AgentInstance
|
agents map[string]*AgentInstance
|
||||||
resolver *routing.RouteResolver
|
resolver *routing.RouteResolver
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
@ -24,6 +25,7 @@ func NewAgentRegistry(
|
||||||
provider providers.LLMProvider,
|
provider providers.LLMProvider,
|
||||||
) *AgentRegistry {
|
) *AgentRegistry {
|
||||||
registry := &AgentRegistry{
|
registry := &AgentRegistry{
|
||||||
|
cfg: cfg,
|
||||||
agents: make(map[string]*AgentInstance),
|
agents: make(map[string]*AgentInstance),
|
||||||
resolver: routing.NewRouteResolver(cfg),
|
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
|
return registry
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,16 +89,43 @@ func (r *AgentRegistry) ListAgentIDs() []string {
|
||||||
return ids
|
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.
|
// CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID.
|
||||||
func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool {
|
func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool {
|
||||||
parent, ok := r.GetAgent(parentAgentID)
|
parent, ok := r.GetAgent(parentAgentID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return false
|
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
|
return false
|
||||||
}
|
}
|
||||||
targetNorm := routing.NormalizeAgentID(targetAgentID)
|
|
||||||
for _, allowed := range parent.Subagents.AllowAgents {
|
for _, allowed := range parent.Subagents.AllowAgents {
|
||||||
if allowed == "*" {
|
if allowed == "*" {
|
||||||
return true
|
return true
|
||||||
|
|
@ -102,6 +137,14 @@ func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bo
|
||||||
return false
|
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
|
// ForEachTool calls fn for every tool registered under the given name
|
||||||
// across all agents. This is useful for propagating dependencies (e.g.
|
// across all agents. This is useful for propagating dependencies (e.g.
|
||||||
// MediaStore) to tools after registry construction.
|
// MediaStore) to tools after registry construction.
|
||||||
|
|
@ -131,11 +174,13 @@ func (r *AgentRegistry) Close() {
|
||||||
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
defer r.mu.RUnlock()
|
defer r.mu.RUnlock()
|
||||||
if agent, ok := r.agents["main"]; ok {
|
if id := r.defaultAgentIDLocked(); id != "" {
|
||||||
|
if agent, ok := r.agents[id]; ok {
|
||||||
return agent
|
return agent
|
||||||
}
|
}
|
||||||
for _, agent := range r.agents {
|
}
|
||||||
return agent
|
for id := range r.agents {
|
||||||
|
return r.agents[id]
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
@ -200,6 +202,112 @@ func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) {
|
||||||
|
|
||||||
agent, _ := registry.GetAgent("no-fallback")
|
agent, _ := registry.GetAgent("no-fallback")
|
||||||
if len(agent.Fallbacks) != 0 {
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,18 @@ func (sq *steeringQueue) lenScope(scope string) int {
|
||||||
return len(sq.queues[normalizeSteeringScope(scope)])
|
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.
|
// setMode updates the steering mode.
|
||||||
func (sq *steeringQueue) setMode(mode SteeringMode) {
|
func (sq *steeringQueue) setMode(mode SteeringMode) {
|
||||||
sq.mu.Lock()
|
sq.mu.Lock()
|
||||||
|
|
@ -290,6 +302,13 @@ func (al *AgentLoop) pendingSteeringCountForScope(scope string) int {
|
||||||
return al.steering.lenScope(scope)
|
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(
|
func (al *AgentLoop) continueWithSteeringMessages(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
agent *AgentInstance,
|
agent *AgentInstance,
|
||||||
|
|
@ -511,6 +530,10 @@ func (al *AgentLoop) HardAbort(sessionKey string) error {
|
||||||
"initial_history_length": ts.initialHistoryLength,
|
"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
|
// IMPORTANT: Trigger cascading cancellation FIRST to stop all child SubTurns
|
||||||
// from adding more messages to the session. This prevents race conditions
|
// from adding more messages to the session. This prevents race conditions
|
||||||
// where rollback happens while children are still writing.
|
// where rollback happens while children are still writing.
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/audio/asr"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||||
|
|
@ -477,6 +478,16 @@ func (p *lateSteeringProvider) GetDefaultModel() string {
|
||||||
return "late-steering-mock"
|
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 {
|
type blockingDirectProvider struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
calls int
|
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) {
|
func TestAgentLoop_Steering_DirectResponseContinuesWithQueuedMessage(t *testing.T) {
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
if err != nil {
|
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.
|
// capturingMockProvider captures messages sent to Chat for inspection.
|
||||||
type capturingMockProvider struct {
|
type capturingMockProvider struct {
|
||||||
response string
|
response string
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,10 @@ type SubTurnConfig struct {
|
||||||
// Used by team tool to enforce token limits across all team members.
|
// Used by team tool to enforce token limits across all team members.
|
||||||
InitialTokenBudget *atomic.Int64
|
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 ======================
|
// ====================== Context Keys ======================
|
||||||
|
|
@ -232,6 +235,7 @@ func (s *AgentLoopSpawner) SpawnSubTurn(
|
||||||
Critical: cfg.Critical,
|
Critical: cfg.Critical,
|
||||||
Timeout: cfg.Timeout,
|
Timeout: cfg.Timeout,
|
||||||
MaxContextRunes: cfg.MaxContextRunes,
|
MaxContextRunes: cfg.MaxContextRunes,
|
||||||
|
TargetAgentID: cfg.TargetAgentID,
|
||||||
}
|
}
|
||||||
|
|
||||||
return spawnSubTurn(ctx, s.al, parentTS, agentCfg)
|
return spawnSubTurn(ctx, s.al, parentTS, agentCfg)
|
||||||
|
|
@ -314,8 +318,9 @@ func spawnSubTurn(
|
||||||
return nil, ErrDepthLimitExceeded
|
return nil, ErrDepthLimitExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Config validation
|
// 2. Config validation: Model is required unless TargetAgentID is set
|
||||||
if cfg.Model == "" {
|
// (the target agent provides its own model).
|
||||||
|
if cfg.Model == "" && cfg.TargetAgentID == "" {
|
||||||
return nil, ErrInvalidSubTurnConfig
|
return nil, ErrInvalidSubTurnConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -333,13 +338,23 @@ func spawnSubTurn(
|
||||||
|
|
||||||
childID := al.generateSubTurnID()
|
childID := al.generateSubTurnID()
|
||||||
|
|
||||||
// Get the agent instance from parent, falling back to the default agent.
|
// Resolve the agent instance for the child turn.
|
||||||
// Wrap it in a shallow copy that uses an ephemeral (in-memory only) session store
|
// When TargetAgentID is set, look up that agent from the registry so the
|
||||||
// so that child turns never pollute or persist to the parent's session history.
|
// child runs with the target's workspace, model, tools, and system prompt.
|
||||||
baseAgent := parentTS.agent
|
// 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 {
|
if baseAgent == nil {
|
||||||
baseAgent = al.registry.GetDefaultAgent()
|
baseAgent = al.registry.GetDefaultAgent()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if baseAgent == nil {
|
if baseAgent == nil {
|
||||||
return nil, errors.New("parent turnState has no agent instance")
|
return nil, errors.New("parent turnState has no agent instance")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -2122,3 +2125,206 @@ func TestSubTurn_IndependentContext(t *testing.T) {
|
||||||
t.Log("✓ SubTurn completed successfully (independent context)")
|
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
203
pkg/agent/tool_allowlist.go
Normal 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) != ""
|
||||||
|
}
|
||||||
184
pkg/agent/tool_allowlist_test.go
Normal file
184
pkg/agent/tool_allowlist_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,20 +26,48 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
|
||||||
al.registerActiveTurn(ts)
|
al.registerActiveTurn(ts)
|
||||||
defer al.clearActiveTurn(ts)
|
defer al.clearActiveTurn(ts)
|
||||||
|
|
||||||
|
if al.takePendingStop(ts.sessionKey) {
|
||||||
|
_ = ts.requestHardAbort()
|
||||||
|
}
|
||||||
|
|
||||||
turnStatus := TurnEndStatusCompleted
|
turnStatus := TurnEndStatusCompleted
|
||||||
defer func() {
|
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(
|
al.emitEvent(
|
||||||
runtimeevents.KindAgentTurnEnd,
|
runtimeevents.KindAgentTurnEnd,
|
||||||
ts.eventMeta("runTurn", "turn.end"),
|
ts.eventMeta("runTurn", "turn.end"),
|
||||||
TurnEndPayload{
|
TurnEndPayload{
|
||||||
Status: turnStatus,
|
Status: turnStatus,
|
||||||
|
Workspace: ts.workspace,
|
||||||
Iterations: ts.currentIteration(),
|
Iterations: ts.currentIteration(),
|
||||||
Duration: time.Since(ts.startedAt),
|
Duration: time.Since(ts.startedAt),
|
||||||
FinalContentLen: ts.finalContentLen(),
|
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(
|
al.emitEvent(
|
||||||
runtimeevents.KindAgentTurnStart,
|
runtimeevents.KindAgentTurnStart,
|
||||||
ts.eventMeta("runTurn", "turn.start"),
|
ts.eventMeta("runTurn", "turn.start"),
|
||||||
|
|
@ -191,7 +219,11 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState, pipeline *Pipel
|
||||||
if finalContent == "" {
|
if finalContent == "" {
|
||||||
finalContent = ts.opts.DefaultResponse
|
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:
|
case ControlToolLoop:
|
||||||
// Execute tools via Pipeline
|
// Execute tools via Pipeline
|
||||||
toolCtrl := pipeline.ExecuteTools(ctx, turnCtx, ts, exec, iteration)
|
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 {
|
if exec.allResponsesHandled {
|
||||||
finalContent = ""
|
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 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) {
|
func (al *AgentLoop) abortTurn(ts *turnState) (turnResult, error) {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"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
|
// 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) {
|
func TestPipeline_CallLLM_WithToolCall(t *testing.T) {
|
||||||
provider := &toolCallRespProvider{
|
provider := &toolCallRespProvider{
|
||||||
toolName: "web_search",
|
toolName: "web_search",
|
||||||
|
|
@ -780,3 +828,30 @@ func TestTurnState_HardAbortRequested(t *testing.T) {
|
||||||
t.Error("expected hard abort to be requested")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ package agent
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -179,10 +180,16 @@ type turnState struct {
|
||||||
turnID string
|
turnID string
|
||||||
agentID string
|
agentID string
|
||||||
sessionKey string
|
sessionKey string
|
||||||
|
activeSkills []string
|
||||||
|
attemptedSkills []string
|
||||||
|
skillContextTrace []SkillContextSnapshot
|
||||||
|
toolKinds []string
|
||||||
|
toolExecutions []ToolExecutionRecord
|
||||||
turnCtx *TurnContext
|
turnCtx *TurnContext
|
||||||
|
|
||||||
channel string
|
channel string
|
||||||
chatID string
|
chatID string
|
||||||
|
workspace string
|
||||||
userMessage string
|
userMessage string
|
||||||
media []string
|
media []string
|
||||||
|
|
||||||
|
|
@ -244,9 +251,11 @@ func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScop
|
||||||
turnID: scope.turnID,
|
turnID: scope.turnID,
|
||||||
agentID: agent.ID,
|
agentID: agent.ID,
|
||||||
sessionKey: opts.Dispatch.SessionKey,
|
sessionKey: opts.Dispatch.SessionKey,
|
||||||
|
activeSkills: activeSkillNames(agent, opts),
|
||||||
turnCtx: cloneTurnContext(scope.context),
|
turnCtx: cloneTurnContext(scope.context),
|
||||||
channel: opts.Dispatch.Channel(),
|
channel: opts.Dispatch.Channel(),
|
||||||
chatID: opts.Dispatch.ChatID(),
|
chatID: opts.Dispatch.ChatID(),
|
||||||
|
workspace: agent.Workspace,
|
||||||
userMessage: opts.Dispatch.UserMessage,
|
userMessage: opts.Dispatch.UserMessage,
|
||||||
media: append([]string(nil), opts.Dispatch.Media...),
|
media: append([]string(nil), opts.Dispatch.Media...),
|
||||||
phase: TurnPhaseSetup,
|
phase: TurnPhaseSetup,
|
||||||
|
|
@ -256,7 +265,10 @@ func newTurnState(agent *AgentInstance, opts processOptions, scope turnEventScop
|
||||||
// Bind session store and capture initial history length for rollback logic
|
// Bind session store and capture initial history length for rollback logic
|
||||||
if agent != nil && agent.Sessions != nil {
|
if agent != nil && agent.Sessions != nil {
|
||||||
ts.session = agent.Sessions
|
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
|
return ts
|
||||||
|
|
@ -375,6 +387,160 @@ func (ts *turnState) finalContentLen() int {
|
||||||
return len(ts.finalContent)
|
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) {
|
func (ts *turnState) setTurnCancel(cancel context.CancelFunc) {
|
||||||
ts.mu.Lock()
|
ts.mu.Lock()
|
||||||
defer ts.mu.Unlock()
|
defer ts.mu.Unlock()
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,8 @@ Notes:
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "elevenlabs-asr",
|
"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 |
|
| 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. |
|
| 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. |
|
| 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`.
|
1. **Preferred path**: resolve `voice.model_name` against `model_list`.
|
||||||
2. If that resolved model is:
|
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 OpenAI-compatible Whisper model, PicoClaw uses the Whisper transcriber.
|
||||||
- an audio-capable chat model, PicoClaw uses `AudioModelTranscriber`.
|
- 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.
|
3. **Fallback path**: if `voice.model_name` is not set, PicoClaw performs a compatibility scan through `model_list` for legacy auto-detected ASR entries.
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,8 @@ model_list:
|
||||||
"model_list": [
|
"model_list": [
|
||||||
{
|
{
|
||||||
"model_name": "elevenlabs-asr",
|
"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` 接口。 |
|
| Whisper 接口模型 | `openai/whisper-1`、`groq/whisper-large-v3` | 使用 OpenAI 兼容的 `/audio/transcriptions` 接口。 |
|
||||||
| 支持音频的聊天模型 **(重构中)** | `openai/gpt-4o-audio-preview`、`gemini/gemini-2.5-flash` | 把音频发给多模态聊天模型,并要求它返回转录结果。 |
|
| 支持音频的聊天模型 **(重构中)** | `openai/gpt-4o-audio-preview`、`gemini/gemini-2.5-flash` | 把音频发给多模态聊天模型,并要求它返回转录结果。 |
|
||||||
|
|
||||||
|
|
@ -142,7 +143,7 @@ PicoClaw 目前主要支持三种 ASR 路径:
|
||||||
|
|
||||||
1. **首选路径**:根据 `voice.model_name` 在 `model_list` 中找到对应模型。
|
1. **首选路径**:根据 `voice.model_name` 在 `model_list` 中找到对应模型。
|
||||||
2. 如果找到的模型属于以下类型:
|
2. 如果找到的模型属于以下类型:
|
||||||
- `elevenlabs/...`,则使用 ElevenLabs transcriber。
|
- `provider=elevenlabs` 的模型,则使用 ElevenLabs transcriber。
|
||||||
- OpenAI 兼容的 Whisper 模型,则使用 Whisper transcriber。
|
- OpenAI 兼容的 Whisper 模型,则使用 Whisper transcriber。
|
||||||
- 支持音频输入的聊天模型,则使用 `AudioModelTranscriber`。
|
- 支持音频输入的聊天模型,则使用 `AudioModelTranscriber`。
|
||||||
3. **回退路径**:如果没有设置 `voice.model_name`,PicoClaw 会为了兼容旧配置,扫描 `model_list` 中可自动识别的 ASR 条目。
|
3. **回退路径**:如果没有设置 `voice.model_name`,PicoClaw 会为了兼容旧配置,扫描 `model_list` 中可自动识别的 ASR 条目。
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,12 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const elevenLabsSupportedModelID = "scribe_v1"
|
||||||
|
|
||||||
|
func ElevenLabsSupportedModelID() string {
|
||||||
|
return elevenLabsSupportedModelID
|
||||||
|
}
|
||||||
|
|
||||||
type Transcriber interface {
|
type Transcriber interface {
|
||||||
Name() string
|
Name() string
|
||||||
Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
|
Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error)
|
||||||
|
|
@ -72,14 +78,23 @@ func whisperModelID(modelCfg *config.ModelConfig) string {
|
||||||
return ""
|
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 {
|
func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber {
|
||||||
if modelCfg == nil {
|
if modelCfg == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
if isElevenLabsTranscriptionModel(modelCfg) {
|
||||||
if protocol == "elevenlabs" && modelCfg.APIKey() != "" {
|
_, modelID := providers.ExtractProtocol(modelCfg)
|
||||||
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase)
|
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID)
|
||||||
}
|
}
|
||||||
if modelID := whisperModelID(modelCfg); modelID != "" {
|
if modelID := whisperModelID(modelCfg); modelID != "" {
|
||||||
return NewWhisperTranscriber(modelCfg)
|
return NewWhisperTranscriber(modelCfg)
|
||||||
|
|
@ -95,9 +110,9 @@ func fallbackTranscriberFromModelConfig(modelCfg *config.ModelConfig) Transcribe
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
protocol, _ := providers.ExtractProtocol(modelCfg)
|
if isElevenLabsTranscriptionModel(modelCfg) {
|
||||||
if protocol == "elevenlabs" && modelCfg.APIKey() != "" {
|
_, modelID := providers.ExtractProtocol(modelCfg)
|
||||||
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase)
|
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase, modelID)
|
||||||
}
|
}
|
||||||
if modelID := whisperModelID(modelCfg); modelID != "" {
|
if modelID := whisperModelID(modelCfg); modelID != "" {
|
||||||
return NewWhisperTranscriber(modelCfg)
|
return NewWhisperTranscriber(modelCfg)
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,21 @@ func TestDetectTranscriber(t *testing.T) {
|
||||||
},
|
},
|
||||||
wantName: "elevenlabs",
|
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",
|
name: "voice model name alias selects whisper transcriber for groq",
|
||||||
cfg: &config.Config{
|
cfg: &config.Config{
|
||||||
|
|
|
||||||
|
|
@ -20,19 +20,24 @@ import (
|
||||||
type ElevenLabsTranscriber struct {
|
type ElevenLabsTranscriber struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
apiBase string
|
apiBase string
|
||||||
|
modelID string
|
||||||
httpClient *http.Client
|
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 != ""})
|
logger.DebugCF("voice", "Creating ElevenLabs transcriber", map[string]any{"has_api_key": apiKey != ""})
|
||||||
|
|
||||||
if apiBase == "" {
|
if apiBase == "" {
|
||||||
apiBase = "https://api.elevenlabs.io"
|
apiBase = "https://api.elevenlabs.io"
|
||||||
}
|
}
|
||||||
|
if modelID == "" || modelID != ElevenLabsSupportedModelID() {
|
||||||
|
modelID = ElevenLabsSupportedModelID()
|
||||||
|
}
|
||||||
|
|
||||||
return &ElevenLabsTranscriber{
|
return &ElevenLabsTranscriber{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
apiBase: apiBase,
|
apiBase: apiBase,
|
||||||
|
modelID: modelID,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 120 * time.Second,
|
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)
|
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)
|
return nil, fmt.Errorf("failed to write model_id field: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,14 @@ package asr
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -14,7 +18,7 @@ import (
|
||||||
var _ Transcriber = (*ElevenLabsTranscriber)(nil)
|
var _ Transcriber = (*ElevenLabsTranscriber)(nil)
|
||||||
|
|
||||||
func TestElevenLabsTranscriberName(t *testing.T) {
|
func TestElevenLabsTranscriberName(t *testing.T) {
|
||||||
tr := NewElevenLabsTranscriber("sk_test", "")
|
tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1")
|
||||||
if got := tr.Name(); got != "elevenlabs" {
|
if got := tr.Name(); got != "elevenlabs" {
|
||||||
t.Errorf("Name() = %q, want %q", 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" {
|
if r.Header.Get("Xi-Api-Key") != "sk_test" {
|
||||||
t.Errorf("unexpected xi-api-key header: %s", r.Header.Get("Xi-Api-Key"))
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(TranscriptionResponse{
|
_ = json.NewEncoder(w).Encode(TranscriptionResponse{
|
||||||
Text: "hello from elevenlabs",
|
Text: "hello from elevenlabs",
|
||||||
|
|
@ -43,7 +76,7 @@ func TestElevenLabsTranscribe(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
tr := NewElevenLabsTranscriber("sk_test", "")
|
tr := NewElevenLabsTranscriber("sk_test", "", "scribe_v1")
|
||||||
tr.apiBase = srv.URL
|
tr.apiBase = srv.URL
|
||||||
|
|
||||||
resp, err := tr.Transcribe(context.Background(), audioPath)
|
resp, err := tr.Transcribe(context.Background(), audioPath)
|
||||||
|
|
@ -64,7 +97,7 @@ func TestElevenLabsTranscribe(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
tr := NewElevenLabsTranscriber("sk_bad", "")
|
tr := NewElevenLabsTranscriber("sk_bad", "", "scribe_v1")
|
||||||
tr.apiBase = srv.URL
|
tr.apiBase = srv.URL
|
||||||
|
|
||||||
_, err := tr.Transcribe(context.Background(), audioPath)
|
_, err := tr.Transcribe(context.Background(), audioPath)
|
||||||
|
|
@ -74,10 +107,54 @@ func TestElevenLabsTranscribe(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("missing file", func(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"))
|
_, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg"))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for missing file, got 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1310,6 +1310,7 @@ make test # Full test suite
|
||||||
| `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge mode) |
|
| `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge mode) |
|
||||||
| `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (Native whatsmeow mode) |
|
| `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (Native whatsmeow mode) |
|
||||||
| `pkg/channels/maixcam/` | `"maixcam"` | — |
|
| `pkg/channels/maixcam/` | `"maixcam"` | — |
|
||||||
|
| `pkg/channels/mqtt/` | `"mqtt"` | — |
|
||||||
| `pkg/channels/pico/` | `"pico"` | TypingCapable, PlaceholderCapable, MessageEditor, WebhookHandler |
|
| `pkg/channels/pico/` | `"pico"` | TypingCapable, PlaceholderCapable, MessageEditor, WebhookHandler |
|
||||||
|
|
||||||
### A.3 Interface Quick Reference
|
### A.3 Interface Quick Reference
|
||||||
|
|
|
||||||
|
|
@ -1308,6 +1308,7 @@ make test # 全量测试
|
||||||
| `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge 模式) |
|
| `pkg/channels/whatsapp/` | `"whatsapp"` | — (Bridge 模式) |
|
||||||
| `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (原生 whatsmeow 模式) |
|
| `pkg/channels/whatsapp_native/` | `"whatsapp_native"` | — (原生 whatsmeow 模式) |
|
||||||
| `pkg/channels/maixcam/` | `"maixcam"` | — |
|
| `pkg/channels/maixcam/` | `"maixcam"` | — |
|
||||||
|
| `pkg/channels/mqtt/` | `"mqtt"` | — |
|
||||||
| `pkg/channels/pico/` | `"pico"` | TypingCapable, PlaceholderCapable, MessageEditor, WebhookHandler |
|
| `pkg/channels/pico/` | `"pico"` | TypingCapable, PlaceholderCapable, MessageEditor, WebhookHandler |
|
||||||
|
|
||||||
### A.3 接口速查表
|
### A.3 接口速查表
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
package line
|
package line
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/hmac"
|
"errors"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"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/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
|
@ -24,13 +22,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
lineAPIBase = "https://api.line.me/v2/bot"
|
lineContentEndpoint = "https://api-data.line.me/v2/bot/message/%s/content"
|
||||||
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"
|
|
||||||
lineReplyTokenMaxAge = 25 * time.Second
|
lineReplyTokenMaxAge = 25 * time.Second
|
||||||
|
|
||||||
// Limit request body to prevent memory exhaustion (DoS).
|
// Limit request body to prevent memory exhaustion (DoS).
|
||||||
|
|
@ -45,12 +37,11 @@ type replyTokenEntry struct {
|
||||||
|
|
||||||
// LINEChannel implements the Channel interface for LINE Official Account
|
// LINEChannel implements the Channel interface for LINE Official Account
|
||||||
// using the LINE Messaging API with HTTP webhook for receiving messages
|
// 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 {
|
type LINEChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
config *config.LINESettings
|
config *config.LINESettings
|
||||||
infoClient *http.Client // for bot info lookups (short timeout)
|
client *messaging_api.MessagingApiAPI
|
||||||
apiClient *http.Client // for messaging API calls
|
|
||||||
botUserID string // Bot's user ID
|
botUserID string // Bot's user ID
|
||||||
botBasicID string // Bot's basic ID (e.g. @216ru...)
|
botBasicID string // Bot's basic ID (e.g. @216ru...)
|
||||||
botDisplayName string // Bot's display name for text-based mention detection
|
botDisplayName string // Bot's display name for text-based mention detection
|
||||||
|
|
@ -70,6 +61,14 @@ func NewLINEChannel(
|
||||||
return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
|
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,
|
base := channels.NewBaseChannel("line", cfg, messageBus, bc.AllowFrom,
|
||||||
channels.WithMaxMessageLength(5000),
|
channels.WithMaxMessageLength(5000),
|
||||||
channels.WithGroupTrigger(bc.GroupTrigger),
|
channels.WithGroupTrigger(bc.GroupTrigger),
|
||||||
|
|
@ -79,8 +78,7 @@ func NewLINEChannel(
|
||||||
return &LINEChannel{
|
return &LINEChannel{
|
||||||
BaseChannel: base,
|
BaseChannel: base,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
infoClient: &http.Client{Timeout: 10 * time.Second},
|
client: client,
|
||||||
apiClient: &http.Client{Timeout: 30 * time.Second},
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -91,11 +89,15 @@ func (c *LINEChannel) Start(ctx context.Context) error {
|
||||||
c.ctx, c.cancel = context.WithCancel(ctx)
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
||||||
|
|
||||||
// Fetch bot profile to get bot's userId for mention detection
|
// 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{
|
logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
|
c.botUserID = info.UserId
|
||||||
|
c.botBasicID = info.BasicId
|
||||||
|
c.botDisplayName = info.DisplayName
|
||||||
logger.InfoCF("line", "Bot info fetched", map[string]any{
|
logger.InfoCF("line", "Bot info fetched", map[string]any{
|
||||||
"bot_user_id": c.botUserID,
|
"bot_user_id": c.botUserID,
|
||||||
"basic_id": c.botBasicID,
|
"basic_id": c.botBasicID,
|
||||||
|
|
@ -108,39 +110,6 @@ func (c *LINEChannel) Start(ctx context.Context) error {
|
||||||
return nil
|
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.
|
// Stop gracefully stops the LINE channel.
|
||||||
func (c *LINEChannel) Stop(ctx context.Context) error {
|
func (c *LINEChannel) Stop(ctx context.Context) error {
|
||||||
logger.InfoC("line", "Stopping LINE channel")
|
logger.InfoC("line", "Stopping LINE channel")
|
||||||
|
|
@ -174,140 +143,70 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
logger.ErrorCF("line", "Failed to read request body", map[string]any{
|
var maxBytesErr *http.MaxBytesError
|
||||||
"error": err.Error(),
|
if errors.As(err, &maxBytesErr) {
|
||||||
})
|
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if int64(len(body)) > maxWebhookBodySize {
|
|
||||||
logger.WarnC("line", "Webhook request body too large, rejected")
|
logger.WarnC("line", "Webhook request body too large, rejected")
|
||||||
http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge)
|
http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge)
|
||||||
return
|
} else if errors.Is(err, webhook.ErrInvalidSignature) {
|
||||||
}
|
|
||||||
|
|
||||||
signature := r.Header.Get("X-Line-Signature")
|
|
||||||
if !c.verifySignature(body, signature) {
|
|
||||||
logger.WarnC("line", "Invalid webhook signature")
|
logger.WarnC("line", "Invalid webhook signature")
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
http.Error(w, "Forbidden", http.StatusForbidden)
|
||||||
return
|
} else {
|
||||||
}
|
logger.ErrorCF("line", "Failed to parse webhook request", map[string]any{
|
||||||
|
|
||||||
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(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return 200 immediately, process events asynchronously
|
// Return 200 immediately, process events asynchronously
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
for _, event := range payload.Events {
|
for _, event := range cb.Events {
|
||||||
go c.processEvent(event)
|
go c.processEvent(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// verifySignature validates the X-Line-Signature using HMAC-SHA256.
|
func (c *LINEChannel) processEvent(event webhook.EventInterface) {
|
||||||
func (c *LINEChannel) verifySignature(body []byte, signature string) bool {
|
msgEvent, ok := event.(webhook.MessageEvent)
|
||||||
if signature == "" {
|
if !ok {
|
||||||
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" {
|
|
||||||
logger.DebugCF("line", "Ignoring non-message event", map[string]any{
|
logger.DebugCF("line", "Ignoring non-message event", map[string]any{
|
||||||
"type": event.Type,
|
"type": event.GetType(),
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
senderID := event.Source.UserID
|
senderID, chatID, sourceType := c.resolveSource(msgEvent.Source)
|
||||||
chatID := c.resolveChatID(event.Source)
|
isGroup := sourceType == "group" || sourceType == "room"
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store reply token for later use
|
// Store reply token for later use
|
||||||
if event.ReplyToken != "" {
|
if msgEvent.ReplyToken != "" {
|
||||||
c.replyTokens.Store(chatID, replyTokenEntry{
|
c.replyTokens.Store(chatID, replyTokenEntry{
|
||||||
token: event.ReplyToken,
|
token: msgEvent.ReplyToken,
|
||||||
timestamp: time.Now(),
|
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 content string
|
||||||
var mediaPaths []string
|
var mediaPaths []string
|
||||||
|
var messageID string
|
||||||
scope := channels.BuildMediaScope("line", chatID, msg.ID)
|
var quoteToken string
|
||||||
|
var isMentioned bool
|
||||||
|
|
||||||
// Helper to register a local file with the media store
|
// 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 {
|
if store := c.GetMediaStore(); store != nil {
|
||||||
ref, err := store.Store(localPath, media.MediaMeta{
|
ref, err := store.Store(localPath, media.MediaMeta{
|
||||||
Filename: filename,
|
Filename: filename,
|
||||||
Source: "line",
|
Source: "line",
|
||||||
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
|
||||||
}, scope)
|
}, scope)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return ref
|
return ref
|
||||||
|
|
@ -316,37 +215,70 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
return localPath // fallback
|
return localPath // fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
switch msg.Type {
|
switch msg := msgEvent.Message.(type) {
|
||||||
case "text":
|
case webhook.TextMessageContent:
|
||||||
|
messageID = msg.Id
|
||||||
content = msg.Text
|
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
|
// Strip bot mention from text in group chats
|
||||||
if isGroup {
|
if isGroup {
|
||||||
content = c.stripBotMention(content, msg)
|
content = c.stripBotMention(content, msg)
|
||||||
}
|
}
|
||||||
case "image":
|
case webhook.ImageMessageContent:
|
||||||
localPath := c.downloadContent(msg.ID, "image.jpg")
|
messageID = msg.Id
|
||||||
if localPath != "" {
|
if msg.QuoteToken != "" {
|
||||||
mediaPaths = append(mediaPaths, storeMedia(localPath, "image.jpg"))
|
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]"
|
content = "[image]"
|
||||||
}
|
}
|
||||||
case "audio":
|
case webhook.AudioMessageContent:
|
||||||
localPath := c.downloadContent(msg.ID, "audio.m4a")
|
messageID = msg.Id
|
||||||
if localPath != "" {
|
if localPath := c.downloadContent(msg.Id, "audio.m4a"); localPath != "" {
|
||||||
mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a"))
|
scope := channels.BuildMediaScope("line", chatID, msg.Id)
|
||||||
|
mediaPaths = append(mediaPaths, storeMedia(localPath, "audio.m4a", scope))
|
||||||
content = "[audio]"
|
content = "[audio]"
|
||||||
}
|
}
|
||||||
case "video":
|
case webhook.VideoMessageContent:
|
||||||
localPath := c.downloadContent(msg.ID, "video.mp4")
|
messageID = msg.Id
|
||||||
if localPath != "" {
|
if msg.QuoteToken != "" {
|
||||||
mediaPaths = append(mediaPaths, storeMedia(localPath, "video.mp4"))
|
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]"
|
content = "[video]"
|
||||||
}
|
}
|
||||||
case "file":
|
case webhook.FileMessageContent:
|
||||||
|
messageID = msg.Id
|
||||||
content = "[file]"
|
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]"
|
content = "[sticker]"
|
||||||
default:
|
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) == "" {
|
if strings.TrimSpace(content) == "" {
|
||||||
|
|
@ -354,9 +286,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// In group chats, apply unified group trigger filtering
|
// In group chats, apply unified group trigger filtering
|
||||||
isMentioned := false
|
|
||||||
if isGroup {
|
if isGroup {
|
||||||
isMentioned = c.isBotMentioned(msg)
|
|
||||||
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
||||||
if !respond {
|
if !respond {
|
||||||
logger.DebugCF("line", "Ignoring group message by group trigger", map[string]any{
|
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{
|
metadata := map[string]string{
|
||||||
"platform": "line",
|
"platform": "line",
|
||||||
"source_type": event.Source.Type,
|
"source_type": sourceType,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.DebugCF("line", "Received message", map[string]any{
|
logger.DebugCF("line", "Received message", map[string]any{
|
||||||
"sender_id": senderID,
|
"sender_id": senderID,
|
||||||
"chat_id": chatID,
|
"chat_id": chatID,
|
||||||
"message_type": msg.Type,
|
"message_type": msgEvent.Message.GetType(),
|
||||||
"is_group": isGroup,
|
"is_group": isGroup,
|
||||||
"preview": utils.Truncate(content, 50),
|
"preview": utils.Truncate(content, 50),
|
||||||
})
|
})
|
||||||
|
|
@ -395,16 +325,16 @@ func (c *LINEChannel) processEvent(event lineEvent) {
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
ChatType: map[bool]string{true: "group", false: "direct"}[isGroup],
|
ChatType: map[bool]string{true: "group", false: "direct"}[isGroup],
|
||||||
SenderID: senderID,
|
SenderID: senderID,
|
||||||
MessageID: msg.ID,
|
MessageID: messageID,
|
||||||
Mentioned: isMentioned,
|
Mentioned: isMentioned,
|
||||||
Raw: metadata,
|
Raw: metadata,
|
||||||
}
|
}
|
||||||
if event.ReplyToken != "" {
|
if msgEvent.ReplyToken != "" {
|
||||||
inboundCtx.ReplyHandles = map[string]string{
|
inboundCtx.ReplyHandles = map[string]string{
|
||||||
"reply_token": event.ReplyToken,
|
"reply_token": msgEvent.ReplyToken,
|
||||||
}
|
}
|
||||||
if msg.QuoteToken != "" {
|
if quoteToken != "" {
|
||||||
inboundCtx.ReplyHandles["quote_token"] = msg.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.
|
// 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
|
// to text-based detection using the bot's display name, since LINE may
|
||||||
// not include userId in mentionees for Official Accounts.
|
// not include userId in mentionees for Official Accounts.
|
||||||
func (c *LINEChannel) isBotMentioned(msg lineMessage) bool {
|
func (c *LINEChannel) isBotMentioned(msg webhook.TextMessageContent) bool {
|
||||||
// Check mention metadata
|
|
||||||
if msg.Mention != nil {
|
if msg.Mention != nil {
|
||||||
for _, m := range msg.Mention.Mentionees {
|
for _, m := range msg.Mention.Mentionees {
|
||||||
if m.Type == "all" {
|
switch mentionee := m.(type) {
|
||||||
|
case webhook.AllMentionee:
|
||||||
|
return true
|
||||||
|
case webhook.UserMentionee:
|
||||||
|
if mentionee.IsSelf {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if c.botUserID != "" && m.UserID == c.botUserID {
|
if c.botUserID != "" && mentionee.UserId == c.botUserID {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
// Check if mentionee text overlaps with bot display name
|
||||||
// Mention metadata exists with mentionees but bot not matched by userId.
|
if c.botDisplayName != "" && mentionee.Index >= 0 && mentionee.Length > 0 {
|
||||||
// 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 {
|
|
||||||
runes := []rune(msg.Text)
|
runes := []rune(msg.Text)
|
||||||
end := m.Index + m.Length
|
end := int(mentionee.Index) + int(mentionee.Length)
|
||||||
if end <= len(runes) {
|
if end <= len(runes) {
|
||||||
mentionText := string(runes[m.Index:end])
|
mentionText := string(runes[mentionee.Index:end])
|
||||||
if strings.Contains(mentionText, c.botDisplayName) {
|
if strings.Contains(mentionText, c.botDisplayName) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -454,30 +382,43 @@ func (c *LINEChannel) isBotMentioned(msg lineMessage) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
// stripBotMention removes the @BotName mention text from the message.
|
// 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
|
stripped := false
|
||||||
|
|
||||||
// Try to strip using mention metadata indices
|
|
||||||
if msg.Mention != nil {
|
if msg.Mention != nil {
|
||||||
runes := []rune(text)
|
runes := []rune(text)
|
||||||
for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- {
|
for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- {
|
||||||
m := msg.Mention.Mentionees[i]
|
m := msg.Mention.Mentionees[i]
|
||||||
// Strip if userId matches OR if the mention text contains the bot display name
|
|
||||||
shouldStrip := false
|
shouldStrip := false
|
||||||
if c.botUserID != "" && m.UserID == c.botUserID {
|
var index, length int32
|
||||||
|
|
||||||
|
switch mentionee := m.(type) {
|
||||||
|
case webhook.UserMentionee:
|
||||||
|
index = mentionee.Index
|
||||||
|
length = mentionee.Length
|
||||||
|
if mentionee.IsSelf {
|
||||||
shouldStrip = true
|
shouldStrip = true
|
||||||
} else if c.botDisplayName != "" && m.Index >= 0 && m.Length > 0 {
|
} else if c.botUserID != "" && mentionee.UserId == c.botUserID {
|
||||||
end := m.Index + m.Length
|
shouldStrip = true
|
||||||
|
} else if c.botDisplayName != "" && index >= 0 && length > 0 {
|
||||||
|
end := int(index) + int(length)
|
||||||
if end <= len(runes) {
|
if end <= len(runes) {
|
||||||
mentionText := string(runes[m.Index:end])
|
mentionText := string(runes[index:end])
|
||||||
if strings.Contains(mentionText, c.botDisplayName) {
|
if strings.Contains(mentionText, c.botDisplayName) {
|
||||||
shouldStrip = true
|
shouldStrip = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case webhook.AllMentionee:
|
||||||
|
// Don't strip @All mentions
|
||||||
|
continue
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
if shouldStrip {
|
if shouldStrip {
|
||||||
start := m.Index
|
start := int(index)
|
||||||
end := m.Index + m.Length
|
end := int(index) + int(length)
|
||||||
if start >= 0 && end <= len(runes) {
|
if start >= 0 && end <= len(runes) {
|
||||||
runes = append(runes[:start], runes[end:]...)
|
runes = append(runes[:start], runes[end:]...)
|
||||||
stripped = true
|
stripped = true
|
||||||
|
|
@ -497,16 +438,20 @@ func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string {
|
||||||
return strings.TrimSpace(text)
|
return strings.TrimSpace(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveChatID determines the chat ID from the event source.
|
// resolveSource extracts senderID, chatID, and source type from the event source.
|
||||||
// For group/room messages, use the group/room ID; for 1:1, use the user ID.
|
func (c *LINEChannel) resolveSource(source webhook.SourceInterface) (senderID, chatID, sourceType string) {
|
||||||
func (c *LINEChannel) resolveChatID(source lineSource) string {
|
switch src := source.(type) {
|
||||||
switch source.Type {
|
case webhook.GroupSource:
|
||||||
case "group":
|
return src.UserId, src.GroupId, "group"
|
||||||
return source.GroupID
|
case webhook.RoomSource:
|
||||||
case "room":
|
return src.UserId, src.RoomId, "room"
|
||||||
return source.RoomID
|
case webhook.UserSource:
|
||||||
|
return src.UserId, src.UserId, "user"
|
||||||
default:
|
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)
|
quoteToken = qt.(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
textMsg := messaging_api.TextMessage{
|
||||||
|
Text: msg.Content,
|
||||||
|
QuoteToken: quoteToken,
|
||||||
|
}
|
||||||
|
|
||||||
// Try reply token first (free, valid for ~25 seconds)
|
// Try reply token first (free, valid for ~25 seconds)
|
||||||
if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok {
|
if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok {
|
||||||
tokenEntry := entry.(replyTokenEntry)
|
tokenEntry := entry.(replyTokenEntry)
|
||||||
if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
|
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{
|
logger.DebugCF("line", "Message sent via Reply API", map[string]any{
|
||||||
"chat_id": msg.ChatID,
|
"chat_id": msg.ChatID,
|
||||||
"quoted": quoteToken != "",
|
"quoted": quoteToken != "",
|
||||||
})
|
})
|
||||||
return nil, nil
|
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
|
// 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.
|
// 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)
|
caption = fmt.Sprintf("[%s: %s]", part.Type, part.Filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.sendPush(ctx, msg.ChatID, caption, ""); err != nil {
|
textMsg := messaging_api.TextMessage{Text: caption}
|
||||||
return nil, err
|
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
|
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.
|
// StartTyping implements channels.TypingCapable using LINE's loading animation.
|
||||||
//
|
//
|
||||||
// NOTE: The LINE loading animation API only works for 1:1 chats.
|
// 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
|
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.
|
// sendLoading sends a loading animation indicator to the chat.
|
||||||
func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error {
|
func (c *LINEChannel) sendLoading(ctx context.Context, chatID string) error {
|
||||||
payload := map[string]any{
|
req := &messaging_api.ShowLoadingAnimationRequest{
|
||||||
"chatId": chatID,
|
ChatId: chatID,
|
||||||
"loadingSeconds": 60,
|
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.
|
// downloadContent downloads media content from the LINE content 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.
|
|
||||||
func (c *LINEChannel) downloadContent(messageID, filename string) string {
|
func (c *LINEChannel) downloadContent(messageID, filename string) string {
|
||||||
url := fmt.Sprintf(lineContentEndpoint, messageID)
|
url := fmt.Sprintf(lineContentEndpoint, messageID)
|
||||||
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWebhookRejectsOversizedBody(t *testing.T) {
|
func TestWebhookRejectsOversizedBody(t *testing.T) {
|
||||||
ch := &LINEChannel{}
|
ch := &LINEChannel{config: &config.LINESettings{}}
|
||||||
|
|
||||||
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
|
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
|
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
|
||||||
|
|
@ -25,7 +25,7 @@ func TestWebhookRejectsOversizedBody(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookAcceptsMaxBodySize(t *testing.T) {
|
func TestWebhookAcceptsMaxBodySize(t *testing.T) {
|
||||||
ch := &LINEChannel{}
|
ch := &LINEChannel{config: &config.LINESettings{}}
|
||||||
|
|
||||||
body := bytes.Repeat([]byte("A"), maxWebhookBodySize)
|
body := bytes.Repeat([]byte("A"), maxWebhookBodySize)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
|
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
|
||||||
|
|
@ -40,7 +40,7 @@ func TestWebhookAcceptsMaxBodySize(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) {
|
func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) {
|
||||||
ch := &LINEChannel{}
|
ch := &LINEChannel{config: &config.LINESettings{}}
|
||||||
|
|
||||||
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
|
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
|
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
|
||||||
|
|
@ -55,7 +55,7 @@ func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookRejectsNonPostMethod(t *testing.T) {
|
func TestWebhookRejectsNonPostMethod(t *testing.T) {
|
||||||
ch := &LINEChannel{}
|
ch := &LINEChannel{config: &config.LINESettings{}}
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/webhook", nil)
|
req := httptest.NewRequest(http.MethodGet, "/webhook", nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,10 @@ type Manager struct {
|
||||||
channelHashes map[string]string // channel name → config hash
|
channelHashes map[string]string // channel name → config hash
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type mediaStoreSetter interface {
|
||||||
|
SetMediaStore(s media.MediaStore)
|
||||||
|
}
|
||||||
|
|
||||||
// ManagerOption configures a channel Manager.
|
// ManagerOption configures a channel Manager.
|
||||||
type ManagerOption func(*Manager)
|
type ManagerOption func(*Manager)
|
||||||
|
|
||||||
|
|
@ -485,6 +489,22 @@ func NewManager(
|
||||||
return m, nil
|
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.
|
// GetStreamer implements bus.StreamDelegate.
|
||||||
// It checks if the named channel supports streaming and returns a Streamer.
|
// 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) {
|
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 {
|
} else {
|
||||||
// Inject MediaStore if channel supports it
|
// Inject MediaStore if channel supports it
|
||||||
if m.mediaStore != nil {
|
if m.mediaStore != nil {
|
||||||
if setter, ok := ch.(interface{ SetMediaStore(s media.MediaStore) }); ok {
|
if setter, ok := ch.(mediaStoreSetter); ok {
|
||||||
setter.SetMediaStore(m.mediaStore)
|
setter.SetMediaStore(m.mediaStore)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -668,10 +688,14 @@ func (m *Manager) getChannelConfigAndEnabled(channelName string) (*config.Channe
|
||||||
return bc, true
|
return bc, true
|
||||||
case *config.TeamsWebhookSettings:
|
case *config.TeamsWebhookSettings:
|
||||||
return bc, true
|
return bc, true
|
||||||
|
case *config.SlackWebhookSettings:
|
||||||
|
return bc, true
|
||||||
case *config.DiscordSettings:
|
case *config.DiscordSettings:
|
||||||
return bc, settings.Token.String() != ""
|
return bc, settings.Token.String() != ""
|
||||||
case *config.VKSettings:
|
case *config.VKSettings:
|
||||||
return bc, settings.GroupID != 0 && settings.Token.String() != ""
|
return bc, settings.GroupID != 0 && settings.Token.String() != ""
|
||||||
|
case *config.MQTTSettings:
|
||||||
|
return bc, settings.Broker != "" && settings.AgentID != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return bc, bc.Enabled
|
return bc, bc.Enabled
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,24 @@ func hiddenValues(key string, value map[string]any, ch *config.Channel) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
value["webhooks"] = webhooks
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"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) {
|
func TestStartAll_AllChannelsFail_ReturnsJoinedError(t *testing.T) {
|
||||||
m := newTestManager()
|
m := newTestManager()
|
||||||
errA := errors.New("channel-a start failed")
|
errA := errors.New("channel-a start failed")
|
||||||
|
|
|
||||||
16
pkg/channels/mqtt/init.go
Normal file
16
pkg/channels/mqtt/init.go
Normal 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
255
pkg/channels/mqtt/mqtt.go
Normal 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
|
||||||
|
}
|
||||||
263
pkg/channels/slack_webhook/convert.go
Normal file
263
pkg/channels/slack_webhook/convert.go
Normal 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
|
||||||
|
}
|
||||||
187
pkg/channels/slack_webhook/convert_test.go
Normal file
187
pkg/channels/slack_webhook/convert_test.go
Normal 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")
|
||||||
|
}
|
||||||
32
pkg/channels/slack_webhook/init.go
Normal file
32
pkg/channels/slack_webhook/init.go
Normal 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
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
316
pkg/channels/slack_webhook/slack_webhook.go
Normal file
316
pkg/channels/slack_webhook/slack_webhook.go
Normal 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)
|
||||||
|
}
|
||||||
281
pkg/channels/slack_webhook/slack_webhook_test.go
Normal file
281
pkg/channels/slack_webhook/slack_webhook_test.go
Normal 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])
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
@ -43,11 +44,14 @@ var (
|
||||||
reInlineCode = regexp.MustCompile("`([^`]+)`")
|
reInlineCode = regexp.MustCompile("`([^`]+)`")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultMediaGroupDelay = 500 * time.Millisecond
|
||||||
|
|
||||||
type TelegramChannel struct {
|
type TelegramChannel struct {
|
||||||
*channels.BaseChannel
|
*channels.BaseChannel
|
||||||
bot *telego.Bot
|
bot *telego.Bot
|
||||||
bh *th.BotHandler
|
bh *th.BotHandler
|
||||||
bc *config.Channel
|
bc *config.Channel
|
||||||
|
chatIDsMu sync.Mutex
|
||||||
chatIDs map[string]int64
|
chatIDs map[string]int64
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
|
@ -57,6 +61,21 @@ type TelegramChannel struct {
|
||||||
registerFunc func(context.Context, []commands.Definition) error
|
registerFunc func(context.Context, []commands.Definition) error
|
||||||
commandRegDelayFn func(int) time.Duration
|
commandRegDelayFn func(int) time.Duration
|
||||||
commandRegCancel context.CancelFunc
|
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(
|
func NewTelegramChannel(
|
||||||
|
|
@ -112,11 +131,21 @@ func NewTelegramChannel(
|
||||||
bc: bc,
|
bc: bc,
|
||||||
chatIDs: make(map[string]int64),
|
chatIDs: make(map[string]int64),
|
||||||
tgCfg: telegramCfg,
|
tgCfg: telegramCfg,
|
||||||
|
|
||||||
|
mediaGroups: make(map[string]*telegramMediaGroup),
|
||||||
|
mediaGroupDelay: telegramMediaGroupDelay(telegramCfg),
|
||||||
}
|
}
|
||||||
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
|
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
|
||||||
return ch, nil
|
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 {
|
func (c *TelegramChannel) Start(ctx context.Context) error {
|
||||||
logger.InfoC("telegram", "Starting Telegram bot (polling mode)...")
|
logger.InfoC("telegram", "Starting Telegram bot (polling mode)...")
|
||||||
|
|
||||||
|
|
@ -167,6 +196,7 @@ func (c *TelegramChannel) Stop(ctx context.Context) error {
|
||||||
if c.bh != nil {
|
if c.bh != nil {
|
||||||
_ = c.bh.StopWithContext(ctx)
|
_ = c.bh.StopWithContext(ctx)
|
||||||
}
|
}
|
||||||
|
c.flushPendingMediaGroups(ctx)
|
||||||
|
|
||||||
// Cancel our context (stops long polling)
|
// Cancel our context (stops long polling)
|
||||||
if c.cancel != nil {
|
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 {
|
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 {
|
if message == nil {
|
||||||
return fmt.Errorf("message is 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
|
chatID := message.Chat.ID
|
||||||
|
c.chatIDsMu.Lock()
|
||||||
c.chatIDs[platformID] = chatID
|
c.chatIDs[platformID] = chatID
|
||||||
|
c.chatIDsMu.Unlock()
|
||||||
|
|
||||||
content := ""
|
content := ""
|
||||||
mediaPaths := []string{}
|
mediaPaths := []string{}
|
||||||
|
|
@ -764,61 +921,18 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
return localPath // fallback: use raw path
|
return localPath // fallback: use raw path
|
||||||
}
|
}
|
||||||
|
|
||||||
if message.Text != "" {
|
for i, msg := range messages {
|
||||||
content += message.Text
|
if msg == nil {
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
parts := c.collectTelegramMessageParts(ctx, msg, i, len(messages), storeMedia)
|
||||||
if message.Caption != "" {
|
for _, part := range parts.content {
|
||||||
if content != "" {
|
if content != "" {
|
||||||
content += "\n"
|
content += "\n"
|
||||||
}
|
}
|
||||||
content += message.Caption
|
content += part
|
||||||
}
|
|
||||||
|
|
||||||
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"))
|
|
||||||
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]"
|
|
||||||
}
|
}
|
||||||
|
mediaPaths = append(mediaPaths, parts.mediaPaths...)
|
||||||
}
|
}
|
||||||
|
|
||||||
if content == "" && len(mediaPaths) == 0 {
|
if content == "" && len(mediaPaths) == 0 {
|
||||||
|
|
@ -917,6 +1031,74 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
|
||||||
return nil
|
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 {
|
func (c *TelegramChannel) prependTelegramQuotedReply(content string, reply *telego.Message) string {
|
||||||
quoted := strings.TrimSpace(telegramQuotedContent(reply))
|
quoted := strings.TrimSpace(telegramQuotedContent(reply))
|
||||||
if quoted == "" {
|
if quoted == "" {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
"github.com/mymmrac/telego"
|
||||||
ta "github.com/mymmrac/telego/telegoapi"
|
ta "github.com/mymmrac/telego/telegoapi"
|
||||||
|
|
@ -1100,3 +1101,190 @@ func TestHandleMessage_EmptyContent_Ignored(t *testing.T) {
|
||||||
default:
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ func BuiltinDefinitions() []Definition {
|
||||||
return []Definition{
|
return []Definition{
|
||||||
startCommand(),
|
startCommand(),
|
||||||
helpCommand(),
|
helpCommand(),
|
||||||
|
stopCommand(),
|
||||||
showCommand(),
|
showCommand(),
|
||||||
listCommand(),
|
listCommand(),
|
||||||
useCommand(),
|
useCommand(),
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,9 @@ func TestBuiltinHelpHandler_ReturnsFormattedMessage(t *testing.T) {
|
||||||
if !strings.Contains(reply, "/list [models|channels|agents|skills|mcp]") {
|
if !strings.Contains(reply, "/list [models|channels|agents|skills|mcp]") {
|
||||||
t.Fatalf("/help reply missing /list usage, got %q", reply)
|
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>") {
|
||||||
if !strings.Contains(reply, "/use <skill> [message]") {
|
if !strings.Contains(reply, "/use <skill> [message]") {
|
||||||
t.Fatalf("/help reply missing /use usage, got %q", reply)
|
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) {
|
func TestBuiltinShowChannel_PreservesUserVisibleBehavior(t *testing.T) {
|
||||||
defs := BuiltinDefinitions()
|
defs := BuiltinDefinitions()
|
||||||
ex := NewExecutor(NewRegistry(defs), nil)
|
ex := NewExecutor(NewRegistry(defs), nil)
|
||||||
|
|
|
||||||
52
pkg/commands/cmd_stop.go
Normal file
52
pkg/commands/cmd_stop.go
Normal 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
Loading…
Add table
Reference in a new issue