Merge branch 'main' into feat/download-files-on-frontend

This commit is contained in:
afjcjsbx 2026-04-16 21:19:11 +02:00
commit 441e85194a
55 changed files with 3571 additions and 1005 deletions

View file

@ -62,7 +62,7 @@ picoclaw gateway
**4. Telegram command menu (auto-registered at startup)**
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`, `/use`) so command menu and runtime behavior stay in sync.
PicoClaw now keeps command definitions in one shared registry. On startup, Telegram will automatically register supported bot commands (for example `/start`, `/help`, `/show`, `/list`, `/use`, `/btw`) so command menu and runtime behavior stay in sync.
Telegram command menu registration remains channel-local discovery UX; generic command execution is handled centrally in the agent loop via the commands executor.
If command registration fails (network/API transient errors), the channel still starts and PicoClaw retries registration in the background.
@ -73,6 +73,7 @@ You can also manage installed skills directly from Telegram:
- `/use <skill> <message>`
- `/use <skill>` and then send the actual request in the next message
- `/use clear`
- `/btw <question>` to ask an immediate side question without changing the active session history; `/btw` is handled as a no-tool query and does not enter the normal tool-execution flow
**4. Advanced Formatting**
You can set use_markdown_v2: true to enable enhanced formatting options. This allows the bot to utilize the full range of Telegram MarkdownV2 features, including nested styles, spoilers, and custom fixed-width blocks.

View file

@ -103,12 +103,14 @@ Once skills are installed, you can inspect and force them directly from a chat c
- `/use <skill> <message>` forces a specific skill for a single request.
- `/use <skill>` arms that skill for your next message in the same chat session.
- `/use clear` cancels a pending skill override created by `/use <skill>`.
- `/btw <question>` asks an immediate side question without changing the current session history. `/btw` is handled as a no-tool query and does not enter the normal tool-execution flow.
Examples:
```text
/list skills
/use git explain how to squash the last 3 commits
/btw remind me what we already decided about the deploy plan
/use italiapersonalfinance
dammi le ultime news
```
@ -116,7 +118,7 @@ dammi le ultime news
### Unified Command Execution Policy
- Generic slash commands are executed through a single path in `pkg/agent/loop.go` via `commands.Executor`.
- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands at startup.
- Channel adapters no longer consume generic commands locally; they forward inbound text to the bus/agent path. Telegram still auto-registers supported commands such as `/start`, `/help`, `/show`, `/list`, `/use`, and `/btw` at startup.
- Unknown slash command (for example `/foo`) passes through to normal LLM processing.
- Registered but unsupported command on the current channel (for example `/show` on WhatsApp) returns an explicit user-facing error and stops further processing.
@ -823,7 +825,8 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m
"model": "glm-4.7",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
"max_tool_iterations": 20,
"max_parallel_turns": 1
}
},
"providers": {
@ -836,6 +839,8 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m
```
> **Note**: The `providers` format is deprecated. Use the new `model_list` format with `.security.yml` for better security.
>
> **`max_parallel_turns`**: Controls concurrent processing of messages from different sessions. `1` (default) = sequential; `>1` = parallel. Messages from the same session are always serialized. See [Steering docs](../steering.md) for details.
</details>

View file

@ -26,7 +26,8 @@ graph TD
subgraph AgentLoop
BUS[MessageBus]
DRAIN[drainBusToSteering goroutine]
ROUTE{Session Routing}
WP[Worker Pool]
SQ[steeringQueue]
RLI[runLLMIteration]
TE[Tool Execution Loop]
@ -37,8 +38,11 @@ graph TD
DC -->|PublishInbound| BUS
SL -->|PublishInbound| BUS
BUS -->|ConsumeInbound while busy| DRAIN
DRAIN -->|Steer| SQ
BUS -->|ConsumeInbound| ROUTE
ROUTE -->|no active turn| WP
ROUTE -->|active turn exists| SQ
WP -->|Steer| SQ
WP -->|process| RLI
RLI -->|1. initial poll| SQ
TE -->|2. poll after each tool| SQ
@ -47,32 +51,34 @@ graph TD
RLI -->|inject into context| LLM
```
### Bus drain mechanism
### Message routing and worker pool
Channels (Telegram, Discord, etc.) publish messages to the `MessageBus` via `PublishInbound`. Without additional wiring, these messages would sit in the bus buffer until the current `processMessage` finishes — meaning steering would never work for real users.
Channels (Telegram, Discord, etc.) publish messages to the `MessageBus` via `PublishInbound`. The `Run()` loop consumes messages from the bus and routes each one based on its **session key**:
The solution: when `Run()` starts processing a message, it spawns a **drain goroutine** (`drainBusToSteering`) that keeps consuming from the bus and calling `Steer()`. When `processMessage` returns, the drain is canceled and normal consumption resumes.
- **No active turn for the session**: The session key is atomically reserved via `LoadOrStore(sessionKey, struct{}{})`, and a **worker goroutine** is spawned to process the full turn lifecycle.
- **Active turn exists for the session**: The message is enqueued directly into the steering queue via `enqueueSteeringMessage`. It will be picked up by the existing worker's steering drain loop.
- **Non-routable (system)**: Processed synchronously in the main loop.
This enables **parallel processing of messages from different sessions** (up to `max_parallel_turns`) while keeping same-session messages strictly sequential.
```mermaid
sequenceDiagram
participant Bus
participant Run
participant Drain
participant AgentLoop
participant Worker
participant SQ
Run->>Bus: ConsumeInbound() → msg
Run->>Drain: spawn drainBusToSteering(ctx)
Run->>Run: processMessage(msg)
Run->>Run: resolveSteeringTarget(msg) → sessionKey
Note over Drain: running concurrently
Bus-->>Drain: ConsumeInbound() → newMsg
Drain->>AgentLoop: al.transcribeAudioInMessage(ctx, newMsg)
Drain->>AgentLoop: Steer(providers.Message{Content: newMsg.Content})
Run->>Run: processMessage returns
Run->>Drain: cancel context
Note over Drain: exits
alt no active turn
Run->>Run: LoadOrStore(sessionKey, sentinel)
Run->>Worker: spawn worker goroutine
Worker->>Worker: processMessage(msg)
Worker->>SQ: drain steering after turn
else active turn exists
Run->>SQ: enqueueSteeringMessage(msg)
end
```
## Data Structures
@ -121,7 +127,7 @@ A new field was added to `processOptions`:
| `Steer` | `Steer(msg providers.Message) error` | Enqueues a steering message. Returns an error if the queue is full or not initialized. Thread-safe, can be called from any goroutine. |
| `SteeringMode` | `SteeringMode() SteeringMode` | Returns the current dequeue mode. |
| `SetSteeringMode` | `SetSteeringMode(mode SteeringMode)` | Changes the dequeue mode at runtime. |
| `Continue` | `Continue(ctx, sessionKey, channel, chatID) (string, error)` | Resumes an idle agent using pending steering messages. Returns `""` if queue is empty. |
| `Continue` | `Continue(ctx, sessionKey, channel, chatID) (string, error)` | Resumes an idle agent using pending steering messages for the given session. Returns `""` if queue is empty. Uses session-aware active turn checking (won't block on unrelated sessions). |
## Integration into the Agent Loop
@ -280,15 +286,17 @@ flowchart TD
{
"agents": {
"defaults": {
"steering_mode": "one-at-a-time"
"steering_mode": "one-at-a-time",
"max_parallel_turns": 1
}
}
}
```
| Field | Type | Default | Env var |
|-------|------|---------|---------|
| `steering_mode` | `string` | `"one-at-a-time"` | `PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE` |
| Field | Type | Default | Env var | Description |
|-------|------|---------|---------|-------------|
| `steering_mode` | `string` | `"one-at-a-time"` | `PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE` | How the steering queue is drained per poll |
| `max_parallel_turns` | `int` | `1` | `PICOCLAW_AGENTS_DEFAULTS_MAX_PARALLEL_TURNS` | Max concurrent turns. `0` or `1` = sequential; `>1` = parallel across sessions |
## Design decisions and trade-offs
@ -300,7 +308,8 @@ flowchart TD
| `one-at-a-time` as default | Gives the model a chance to react to each steering message individually. More predictable behavior than dumping all messages at once. |
| Skipped tools get explicit error results | The LLM protocol requires a tool result for every tool call in the assistant message. Omitting them would cause API errors. The skip message also informs the model about what was not done. |
| `Continue()` uses `SkipInitialSteeringPoll` | Prevents race conditions and double-dequeuing when resuming an idle agent. |
| Queue stored on `AgentLoop`, not `AgentInstance` | Steering is a loop-level concern (it affects the iteration flow), not a per-agent concern. All agents share the same steering queue since `processMessage` is sequential. |
| Bus drain goroutine in `Run()` | Channels (Telegram, Discord, etc.) publish to the bus via `PublishInbound`. Without the drain, messages would queue in the bus channel buffer and only be consumed after `processMessage` returns — defeating the purpose of steering. The drain goroutine bridges the gap by consuming new bus messages and calling `Steer()` while the agent is busy. |
| Audio transcription before steering | The drain goroutine calls `al.transcribeAudioInMessage(ctx, msg)` before steering, so voice messages are converted to text before the agent sees them. If transcription fails, the error is silently discarded and the original message is steered as-is. |
| Queue stored on `AgentLoop`, not `AgentInstance` | Steering is a loop-level concern (it affects the iteration flow), not a per-agent concern. All agents share the steering queue since `processMessage` is sequential. |
| Worker pool dispatch in `Run()` | Messages are dispatched to a worker pool instead of a single sequential loop. The session key is atomically reserved via `LoadOrStore` before the worker starts, preventing TOCTOU races. Messages from the same session are serialized; different sessions are processed in parallel (up to `max_parallel_turns`). |
| No bus drain goroutine | The old `drainBusToSteering` goroutine has been removed. The main `Run()` loop now checks `activeTurnStates` for each inbound message: if a turn is active for the session, the message is enqueued directly to the steering queue; otherwise a new worker is spawned. This eliminates the complexity of drain cancellation and requeuing. |
| Audio transcription in worker | Audio is transcribed within the worker that processes the turn, not in a separate drain goroutine. |
| `MaxQueueSize = 10` | Prevents unbounded memory growth if a user sends many messages while the agent is busy. Excess messages are dropped with a warning. |

View file

@ -61,11 +61,19 @@ picoclaw gateway
**4. Menu de commandes Telegram (enregistré automatiquement au démarrage)**
PicoClaw conserve les définitions de commandes dans un registre partagé unique. Au démarrage, Telegram enregistre automatiquement les commandes bot prises en charge (par exemple `/start`, `/help`, `/show`, `/list`) afin que le menu de commandes et le comportement à l'exécution restent synchronisés.
PicoClaw conserve les définitions de commandes dans un registre partagé unique. Au démarrage, Telegram enregistre automatiquement les commandes bot prises en charge (par exemple `/start`, `/help`, `/show`, `/list`, `/use`, `/btw`) afin que le menu de commandes et le comportement à l'exécution restent synchronisés.
L'enregistrement du menu de commandes Telegram reste une découverte UX locale au canal ; l'exécution générique des commandes est gérée de manière centralisée dans la boucle agent via l'exécuteur de commandes.
Si l'enregistrement des commandes échoue (erreurs transitoires réseau/API), le canal démarre quand même et PicoClaw réessaie l'enregistrement en arrière-plan.
Vous pouvez aussi gerer les competences installees directement depuis Telegram :
- `/list skills`
- `/use <skill> <message>`
- `/use <skill>` puis envoyer la vraie requete dans le message suivant
- `/use clear`
- `/btw <question>` pour poser une question annexe immediate sans modifier l'historique actif de la session ; `/btw` est traite comme une requete directe sans outils et n'entre pas dans le flux normal d'execution des outils
</details>
<a id="discord"></a>

View file

@ -80,10 +80,30 @@ Pour les configurations avancées/de test, vous pouvez remplacer la racine des c
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
```
### Utiliser les Commandes Depuis les Canaux de Chat
Une fois les compétences installées, vous pouvez aussi les inspecter et les activer directement depuis un canal de chat :
- `/list skills` affiche les noms des compétences installées visibles pour l'agent courant.
- `/use <skill> <message>` force une compétence pour une seule requête.
- `/use <skill>` prépare cette compétence pour votre prochain message dans la meme conversation.
- `/use clear` annule une surcharge de compétence en attente creee via `/use <skill>`.
- `/btw <question>` pose une question annexe immediate sans modifier l'historique courant de la session. `/btw` est traite comme une requete directe sans outils et n'entre pas dans le flux normal d'execution des outils.
Exemples :
```text
/list skills
/use git explique comment squash les 3 derniers commits
/btw rappelle-moi ce qu'on a deja decide pour le plan de deploiement
/use italiapersonalfinance
dammi le ultime news
```
### Politique Unifiée d'Exécution des Commandes
- Les commandes slash génériques sont exécutées via un chemin unique dans `pkg/agent/loop.go` via `commands.Executor`.
- Les adaptateurs de canaux ne consomment plus les commandes génériques localement ; ils transmettent le texte entrant au chemin bus/agent. Telegram enregistre toujours automatiquement les commandes prises en charge au démarrage.
- Les adaptateurs de canaux ne consomment plus les commandes génériques localement ; ils transmettent le texte entrant au chemin bus/agent. Telegram enregistre toujours automatiquement au démarrage les commandes prises en charge, comme `/start`, `/help`, `/show`, `/list`, `/use` et `/btw`.
- Une commande slash inconnue (par exemple `/foo`) passe au traitement LLM normal.
- Une commande enregistrée mais non prise en charge sur le canal actuel (par exemple `/show` sur WhatsApp) renvoie une erreur explicite à l'utilisateur et arrête le traitement ultérieur.

View file

@ -65,7 +65,7 @@ picoclaw gateway
**4. Telegram コマンドメニュー(起動時に自動登録)**
PicoClaw は統一されたコマンド定義を使用します。起動時に Telegram がサポートするコマンド(例: `/start``/help``/show``/list`)を Bot コマンドメニューに自動登録し、メニュー表示と実際の動作を一致させます。
PicoClaw は統一されたコマンド定義を使用します。起動時に Telegram がサポートするコマンド(例: `/start``/help``/show``/list``/use``/btw`)を Bot コマンドメニューに自動登録し、メニュー表示と実際の動作を一致させます。
Telegram 側はコマンドメニュー登録機能を保持し、汎用コマンドの実行は Agent Loop 内の commands executor で統一的に処理されます。
ネットワークや API の一時的なエラーで登録に失敗しても、チャネルの起動はブロックされません。システムがバックグラウンドで自動リトライします。

View file

@ -81,10 +81,30 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
```
### チャットチャネルからスキルとコマンドを使う
スキルをインストールすると、チャットチャネルから直接確認したり明示的に適用したりできます:
- `/list skills` は現在の Agent から見えるインストール済みスキル名を表示します。
- `/use <skill> <message>` は 1 回のリクエストだけそのスキルを強制します。
- `/use <skill>` は同じチャット内の次のメッセージにそのスキルを予約します。
- `/use clear``/use <skill>` で設定した保留中のスキル上書きを解除します。
- `/btw <question>` は現在のセッション履歴を変更せずに即時の横道の質問を送ります。`/btw` はツールなしの直接質問として処理され、通常のツール実行フローには入りません。
例:
```text
/list skills
/use git 直近 3 つのコミットを squash する方法を教えて
/btw さっきのデプロイ方針の結論だけもう一度教えて
/use italiapersonalfinance
dammi le ultime news
```
### 統一コマンド実行ポリシー
- 汎用スラッシュコマンドは `pkg/agent/loop.go` 内の `commands.Executor` を通じて統一的に実行されます。
- チャネルアダプターはローカルで汎用コマンドを消費しなくなりました。受信テキストを bus/agent パスに転送するだけです。Telegram は起動時にサポートするコマンドメニューを自動登録します。
- チャネルアダプターはローカルで汎用コマンドを消費しなくなりました。受信テキストを bus/agent パスに転送するだけです。Telegram は起動時に `/start``/help``/show``/list``/use``/btw` などのサポート済みコマンドを自動登録します。
- 未登録のスラッシュコマンド(例: `/foo`)は通常の LLM 処理にパススルーされます。
- 登録済みだが現在のチャネルでサポートされていないコマンド(例: WhatsApp での `/show`)は、明示的なユーザー向けエラーを返し、以降の処理を停止します。

View file

@ -60,11 +60,19 @@ picoclaw gateway
**4. Menu arahan Telegram (auto-register semasa startup)**
PicoClaw kini menyimpan definisi arahan dalam satu registry bersama. Semasa startup, Telegram akan mendaftarkan arahan bot yang disokong secara automatik (contohnya `/start`, `/help`, `/show`, `/list`) supaya menu arahan dan tingkah laku runtime sentiasa selari.
PicoClaw kini menyimpan definisi arahan dalam satu registry bersama. Semasa startup, Telegram akan mendaftarkan arahan bot yang disokong secara automatik (contohnya `/start`, `/help`, `/show`, `/list`, `/use`, `/btw`) supaya menu arahan dan tingkah laku runtime sentiasa selari.
Pendaftaran menu arahan Telegram kekal sebagai UX penemuan setempat saluran; pelaksanaan arahan generik dikendalikan secara berpusat dalam gelung agen melalui commands executor.
Jika pendaftaran arahan gagal (ralat sementara rangkaian/API), saluran tetap akan bermula dan PicoClaw akan mencuba semula pendaftaran di latar belakang.
Anda juga boleh mengurus skill yang dipasang terus dari Telegram:
- `/list skills`
- `/use <skill> <message>`
- `/use <skill>` kemudian hantar permintaan sebenar dalam mesej seterusnya
- `/use clear`
- `/btw <question>` untuk bertanya soalan sampingan segera tanpa mengubah sejarah sesi aktif; `/btw` dikendalikan sebagai pertanyaan langsung tanpa tool dan tidak memasuki aliran pelaksanaan tool biasa
**4. Pemformatan Lanjutan**
Anda boleh menetapkan `use_markdown_v2: true` untuk mengaktifkan pilihan pemformatan yang lebih maju. Ini membolehkan bot menggunakan keseluruhan set ciri Telegram MarkdownV2, termasuk gaya bersarang, spoiler, dan blok lebar tetap tersuai.

View file

@ -63,10 +63,30 @@ Untuk setup lanjutan/ujian, anda boleh menindih root builtin skills dengan:
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
```
### Menggunakan Skill dan Arahan Dari Saluran Chat
Selepas skill dipasang, anda boleh menyemak dan memaksanya terus dari saluran chat:
- `/list skills` memaparkan nama skill dipasang yang kelihatan kepada agen semasa.
- `/use <skill> <message>` memaksa satu skill untuk satu permintaan sahaja.
- `/use <skill>` menyediakan skill itu untuk mesej anda yang seterusnya dalam chat yang sama.
- `/use clear` membatalkan skill override tertunda yang dibuat melalui `/use <skill>`.
- `/btw <question>` bertanya soalan sampingan segera tanpa mengubah sejarah sesi semasa. `/btw` dikendalikan sebagai pertanyaan langsung tanpa tool dan tidak memasuki aliran pelaksanaan tool biasa.
Contoh:
```text
/list skills
/use git terangkan cara squash 3 commit terakhir
/btw ingatkan saya semula apa keputusan tadi untuk pelan deploy
/use italiapersonalfinance
dammi le ultime news
```
### Polisi Pelaksanaan Arahan Bersepadu
- Generic slash command dilaksanakan melalui satu laluan dalam `pkg/agent/loop.go` melalui `commands.Executor`.
- Adapter saluran tidak lagi menggunakan generic command secara setempat; ia memajukan teks masuk ke laluan bus/agent. Telegram masih auto-register arahan yang disokong semasa startup.
- Adapter saluran tidak lagi menggunakan generic command secara setempat; ia memajukan teks masuk ke laluan bus/agent. Telegram masih auto-register arahan yang disokong semasa startup seperti `/start`, `/help`, `/show`, `/list`, `/use`, dan `/btw`.
- Slash command yang tidak dikenali (contohnya `/foo`) akan diteruskan ke pemprosesan LLM biasa.
- Arahan yang didaftarkan tetapi tidak disokong pada saluran semasa (contohnya `/show` di WhatsApp) akan memulangkan ralat yang jelas kepada pengguna dan menghentikan pemprosesan lanjut.

View file

@ -61,11 +61,19 @@ picoclaw gateway
**4. Menu de comandos do Telegram (registrado automaticamente na inicialização)**
O PicoClaw agora mantém definições de comandos em um registro compartilhado. Na inicialização, o Telegram registrará automaticamente os comandos de bot suportados (por exemplo `/start`, `/help`, `/show`, `/list`) para que o menu de comandos e o comportamento em tempo de execução permaneçam sincronizados.
O PicoClaw agora mantém definições de comandos em um registro compartilhado. Na inicialização, o Telegram registrará automaticamente os comandos de bot suportados (por exemplo `/start`, `/help`, `/show`, `/list`, `/use`, `/btw`) para que o menu de comandos e o comportamento em tempo de execução permaneçam sincronizados.
O registro do menu de comandos do Telegram permanece como descoberta UX local do canal; a execução genérica de comandos é tratada centralmente no loop do agente via commands executor.
Se o registro de comandos falhar (erros transitórios de rede/API), o canal ainda inicia e o PicoClaw tenta novamente o registro em segundo plano.
Voce tambem pode gerenciar skills instaladas diretamente pelo Telegram:
- `/list skills`
- `/use <skill> <message>`
- `/use <skill>` e depois enviar a solicitacao real na proxima mensagem
- `/use clear`
- `/btw <question>` para fazer uma pergunta lateral imediata sem alterar o historico ativo da sessao; `/btw` e tratado como uma consulta direta sem ferramentas e nao entra no fluxo normal de execucao de ferramentas
</details>
<a id="discord"></a>

View file

@ -81,10 +81,30 @@ Para configurações avançadas/de teste, você pode substituir o diretório rai
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
```
### Usando Skills e Comandos em Canais de Chat
Depois que as skills estiverem instaladas, voce pode inspeciona-las e aplica-las diretamente de um canal de chat:
- `/list skills` mostra os nomes das skills instaladas visiveis para o agente atual.
- `/use <skill> <message>` força uma skill para uma unica requisicao.
- `/use <skill>` prepara essa skill para a sua proxima mensagem no mesmo chat.
- `/use clear` cancela uma substituicao pendente criada por `/use <skill>`.
- `/btw <question>` faz uma pergunta lateral imediata sem alterar o historico atual da sessao. `/btw` e tratado como uma consulta direta sem ferramentas e nao entra no fluxo normal de execucao de ferramentas.
Exemplos:
```text
/list skills
/use git explique como fazer squash dos ultimos 3 commits
/btw me relembre o que ja decidimos sobre o plano de deploy
/use italiapersonalfinance
dammi le ultime news
```
### Política Unificada de Execução de Comandos
- Comandos slash genéricos são executados através de um único caminho em `pkg/agent/loop.go` via `commands.Executor`.
- Os adaptadores de canal não consomem mais comandos genéricos localmente; eles encaminham o texto de entrada para o caminho bus/agent. O Telegram ainda registra automaticamente os comandos suportados na inicialização.
- Os adaptadores de canal não consomem mais comandos genéricos localmente; eles encaminham o texto de entrada para o caminho bus/agent. O Telegram ainda registra automaticamente na inicialização comandos suportados como `/start`, `/help`, `/show`, `/list`, `/use` e `/btw`.
- Comando slash desconhecido (por exemplo `/foo`) passa para o processamento normal do LLM.
- Comando registrado mas não suportado no canal atual (por exemplo `/show` no WhatsApp) retorna um erro explícito ao usuário e interrompe o processamento.

View file

@ -170,13 +170,19 @@ This is saved to the session via `AddFullMessage` and sent to the model, so it i
## Automatic bus drain
When the agent loop (`Run()`) starts processing a message, it spawns a background goroutine that keeps consuming new inbound messages from the bus. These messages are automatically redirected into the steering queue via `Steer()`. This means:
When the agent loop (`Run()`) starts, it reads inbound messages from a shared message bus. The routing logic determines how each message is handled:
- Users on any channel (Telegram, Discord, etc.) don't need to do anything special — their messages are automatically captured as steering when the agent is busy
- Audio messages are transcribed before being steered, so the agent receives text. If transcription fails, the original (non-transcribed) message is steered as-is
- Only messages that resolve to the **same steering scope** as the active turn are redirected. Messages for other chats/sessions are requeued onto the inbound bus so they can be processed normally
- `system` inbound messages are not treated as steering input
- When `processMessage` finishes, the drain goroutine is canceled and normal message consumption resumes
1. **No active turn for the message's session** — the message is dispatched to a **worker goroutine** that processes the full turn (LLM calls, tool execution, steering drain)
2. **An active turn already exists for the same session** — the message is enqueued directly into that session's **steering queue** via `enqueueSteeringMessage`. No background drain goroutine is needed
3. **Non-routable message** (e.g. `system`) — processed synchronously in the main loop
This design enables **parallel processing of messages from different sessions** while keeping same-session messages strictly sequential. Key implications:
- Messages from different users/channels are processed **concurrently** (up to `max_parallel_turns`)
- Messages from the same session are **serialized** — subsequent messages go to the steering queue
- Users don't need to do anything special — their messages are automatically captured as steering when the agent is busy for their session
- Audio messages are transcribed within the worker that processes the turn, so the agent receives text
- `system` inbound messages are processed immediately and do not trigger steering
## Steering with media

View file

@ -112,13 +112,17 @@ When the parent task is forcefully aborted (e.g., user interrupts with `/stop`):
## Agent Loop Integration
### Bus Draining During Processing
### Message Routing and Steering
When a message enters the `Run()` loop, the agent starts a `drainBusToSteering` goroutine before calling `processMessage`. This goroutine runs concurrently with the entire processing lifecycle and continuously consumes any new inbound messages from the bus, redirecting them into the **steering queue** instead of dropping them.
When a message enters the `Run()` loop, the agent determines whether to start a new worker or enqueue to steering:
This ensures that if a user sends a follow-up message while the agent is processing (including during SubTurn execution), the message is not lost — it will be picked up between tool call iterations via `dequeueSteeringMessages`.
- If **no active turn** exists for the message's session key, the session is atomically reserved and a **worker goroutine** is spawned. The worker processes the full turn lifecycle: `processMessage` → tool execution → steering drain → `Continue` for queued messages.
- If an **active turn already exists** for the same session, the message is enqueued directly into that session's steering queue. It will be picked up by the existing worker's steering drain loop.
The drain goroutine stops automatically when `processMessage` returns (via a cancellable context).
This ensures that:
- Messages from **different sessions** are processed **in parallel** (up to `max_parallel_turns` concurrent workers)
- Messages from the **same session** are strictly **serialized** — they go to the steering queue and are processed sequentially within the active turn
- No background drain goroutine is needed; steering is handled by the worker itself after processing
### Pending Result Polling
@ -129,7 +133,7 @@ The agent loop polls for async SubTurn results at two points per iteration:
### Turn State Tracking
All active root turns are registered in `AgentLoop.activeTurnStates` (`sync.Map`, keyed by session key). This allows `HardAbort` and `/subagents` observability commands to find and operate on active turns.
All active turns are registered in `AgentLoop.activeTurnStates` (`sync.Map`, keyed by session key). A reservation sentinel is stored atomically via `LoadOrStore` before the worker starts, then replaced with the real `*turnState` when `runTurn` registers. This prevents a TOCTOU race where multiple messages for the same session could spawn concurrent workers. The sentinel is cleaned up by the worker's deferred cleanup. This allows `HardAbort` and `/subagents` observability commands to find and operate on active turns.
## Event Bus Integration
@ -181,10 +185,10 @@ Creates a new spawner instance for the given AgentLoop. Pass the returned value
### Continue
```go
func (al *AgentLoop) Continue(ctx context.Context, sessionKey string) error
func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID string) (string, error)
```
Resumes an idle agent turn by injecting any queued steering messages as a new LLM iteration. Used when the agent is waiting and a deferred steering message needs to be processed without a new inbound message arriving.
Resumes an idle agent turn by dequeuing steering messages for the given session and running them through the agent loop. Returns the response string if processing occurred, or empty string if no steering messages were pending. Uses session-aware active turn checking — it only blocks if a turn is active for the *same* session, not for unrelated sessions.
## Context Propagation

View file

@ -61,11 +61,19 @@ picoclaw gateway
**4. Menu lệnh Telegram (tự động đăng ký khi khởi động)**
PicoClaw hiện lưu trữ định nghĩa lệnh trong một registry chung. Khi khởi động, Telegram sẽ tự động đăng ký các lệnh bot được hỗ trợ (ví dụ `/start`, `/help`, `/show`, `/list`) để menu lệnh và hành vi runtime luôn đồng bộ.
PicoClaw hiện lưu trữ định nghĩa lệnh trong một registry chung. Khi khởi động, Telegram sẽ tự động đăng ký các lệnh bot được hỗ trợ (ví dụ `/start`, `/help`, `/show`, `/list`, `/use`, `/btw`) để menu lệnh và hành vi runtime luôn đồng bộ.
Đăng ký menu lệnh Telegram vẫn là UX khám phá cục bộ của kênh; thực thi lệnh chung được xử lý tập trung trong vòng lặp agent qua commands executor.
Nếu đăng ký lệnh thất bại (lỗi tạm thời mạng/API), kênh vẫn khởi động và PicoClaw thử lại đăng ký trong nền.
Ban cung co the quan ly skill da cai dat truc tiep tu Telegram:
- `/list skills`
- `/use <skill> <message>`
- `/use <skill>` roi gui yeu cau that o tin nhan tiep theo
- `/use clear`
- `/btw <question>` de hoi them mot cau ngoai le ngay lap tuc ma khong thay doi lich su phien dang hoat dong; `/btw` duoc xu ly nhu mot truy van truc tiep khong dung cong cu va khong di vao luong thuc thi cong cu thong thuong
</details>
<a id="discord"></a>

View file

@ -81,10 +81,30 @@ Cho thiết lập nâng cao/test, bạn có thể ghi đè thư mục gốc skil
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
```
### Dung Skill va Lenh Tu Kenh Chat
Sau khi cai dat skill, ban co the xem va ep dung truc tiep tu kenh chat:
- `/list skills` hien ten cac skill da cai dat ma agent hien tai co the dung.
- `/use <skill> <message>` ep dung mot skill cho duy nhat mot yeu cau.
- `/use <skill>` dat san skill do cho tin nhan tiep theo trong cung cuoc tro chuyen.
- `/use clear` huy skill override dang cho duoc tao boi `/use <skill>`.
- `/btw <question>` dat cau hoi phu ngay lap tuc ma khong thay doi lich su phien hien tai. `/btw` duoc xu ly nhu mot truy van truc tiep khong dung cong cu va khong di vao luong thuc thi cong cu thong thuong.
Vi du:
```text
/list skills
/use git giai thich cach squash 3 commit cuoi
/btw nhac lai giup toi chung ta da chot gi cho ke hoach deploy
/use italiapersonalfinance
dammi le ultime news
```
### Chính Sách Thực Thi Lệnh Thống Nhất
- Lệnh slash chung được thực thi qua một đường dẫn duy nhất trong `pkg/agent/loop.go` qua `commands.Executor`.
- Adapter kênh không còn xử lý lệnh chung cục bộ; chúng chuyển tiếp văn bản đầu vào đến đường dẫn bus/agent. Telegram vẫn tự động đăng ký lệnh được hỗ trợ khi khởi động.
- Adapter kênh không còn xử lý lệnh chung cục bộ; chúng chuyển tiếp văn bản đầu vào đến đường dẫn bus/agent. Telegram vẫn tự động đăng ký khi khởi động các lệnh được hỗ trợ như `/start`, `/help`, `/show`, `/list`, `/use`, va `/btw`.
- Lệnh slash không xác định (ví dụ `/foo`) được chuyển sang xử lý LLM bình thường.
- Lệnh đã đăng ký nhưng không được hỗ trợ trên kênh hiện tại (ví dụ `/show` trên WhatsApp) trả về lỗi rõ ràng cho người dùng và dừng xử lý tiếp.

View file

@ -65,7 +65,7 @@ picoclaw gateway
**4. Telegram 命令菜单(启动时自动注册)**
PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start``/help``/show``/list``/use`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
PicoClaw 使用统一的命令定义来源。启动时会自动将 Telegram 支持的命令(例如 `/start``/help``/show``/list``/use``/btw`)注册到 Bot 命令菜单,确保菜单展示与实际行为一致。
Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行统一走 Agent Loop 中的 commands executor。
如果注册因网络或 API 短暂异常失败,不会阻塞 channel 启动;系统会在后台自动重试。
@ -76,6 +76,7 @@ Telegram 侧保留的是命令菜单注册能力;通用命令的实际执行
- `/use <skill> <message>`
- `/use <skill>`,然后在下一条消息里发送真正的请求
- `/use clear`
- `/btw <question>`,用于发起一个不改动当前会话历史的即时旁支提问;`/btw` 会按一次无工具的直接问答处理,不会进入常规的工具执行流程
</details>

View file

@ -101,12 +101,14 @@ export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
- `/use <skill> <message>`:只对当前这一条请求强制使用指定技能。
- `/use <skill>`:为同一会话中的下一条消息预先启用该技能。
- `/use clear`:取消通过 `/use <skill>` 设置的待应用技能。
- `/btw <question>`:发起一个即时的旁支提问,且不改动当前会话历史。`/btw` 会按一次无工具的直接问答处理,不会进入常规的工具执行流程。
示例:
```text
/list skills
/use git explain how to squash the last 3 commits
/btw 帮我回顾一下刚才关于发布方案的结论
/use italiapersonalfinance
dammi le ultime news
```
@ -114,7 +116,7 @@ dammi le ultime news
### 统一命令执行策略
- 通用斜杠命令通过 `pkg/agent/loop.go` 中的 `commands.Executor` 统一执行。
- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单。
- Channel 适配器不再在本地消费通用命令;它们只负责把入站文本转发到 bus/agent 路径。Telegram 仍会在启动时自动注册其支持的命令菜单,例如 `/start``/help``/show``/list``/use``/btw`
- 未注册的斜杠命令(例如 `/foo`)会透传给 LLM 按普通输入处理。
- 已注册但当前 channel 不支持的命令(例如 WhatsApp 上的 `/show`)会返回明确的用户可见错误,并停止后续处理。

View file

@ -111,6 +111,8 @@ func (p *llmHookTestProvider) GetDefaultModel() string {
type llmObserverHook struct {
eventCh chan Event
lastInbound *bus.InboundContext
lastRoute *routing.ResolvedRoute
lastScope *session.SessionScope
}
func (h *llmObserverHook) OnEvent(ctx context.Context, evt Event) error {
@ -129,6 +131,8 @@ func (h *llmObserverHook) BeforeLLM(
) (*LLMHookRequest, HookDecision, error) {
if req.Context != nil {
h.lastInbound = cloneInboundContext(req.Context.Inbound)
h.lastRoute = cloneResolvedRoute(req.Context.Route)
h.lastScope = session.CloneScope(req.Context.Scope)
}
next := req.Clone()
next.Model = "hook-model"
@ -230,6 +234,91 @@ func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) {
}
}
func TestAgentLoop_BtwCommand_UsesLLMHooks(t *testing.T) {
provider := &llmHookTestProvider{}
al, agent, cleanup := newHookTestLoop(t, provider)
defer cleanup()
useTestSideQuestionProvider(al, provider)
hook := &llmObserverHook{eventCh: make(chan Event, 1)}
if err := al.MountHook(NamedHook("llm-observer", hook)); err != nil {
t.Fatalf("MountHook failed: %v", err)
}
response, handled := al.handleCommand(context.Background(), bus.InboundMessage{
Context: bus.InboundContext{
Channel: "cli",
ChatID: "direct",
ChatType: "direct",
SenderID: "hook-user",
},
Content: "/btw hello",
}, agent, &processOptions{
Dispatch: DispatchRequest{
SessionKey: "session-1",
InboundContext: &bus.InboundContext{
Channel: "cli",
ChatID: "direct",
ChatType: "direct",
SenderID: "hook-user",
},
RouteResult: &routing.ResolvedRoute{
AgentID: "main",
Channel: "cli",
AccountID: routing.DefaultAccountID,
SessionPolicy: routing.SessionPolicy{
Dimensions: []string{"sender"},
},
MatchedBy: "default",
},
SessionScope: &session.SessionScope{
Version: session.ScopeVersionV1,
AgentID: "main",
Channel: "cli",
Account: routing.DefaultAccountID,
Dimensions: []string{"sender"},
Values: map[string]string{
"sender": "hook-user",
},
},
UserMessage: "/btw hello",
},
SessionKey: "session-1",
Channel: "cli",
ChatID: "direct",
SenderID: "hook-user",
SenderDisplayName: "Hook User",
})
if !handled {
t.Fatal("expected /btw command to be handled")
}
if response != "hooked content" {
t.Fatalf("expected hooked content, got %q", response)
}
provider.mu.Lock()
lastModel := provider.lastModel
provider.mu.Unlock()
if lastModel != "hook-model" {
t.Fatalf("expected model hook-model, got %q", lastModel)
}
if hook.lastInbound == nil {
t.Fatal("expected hook to receive inbound context")
}
if hook.lastInbound.Channel != "cli" || hook.lastInbound.SenderID != "hook-user" {
t.Fatalf("hook inbound context = %+v", hook.lastInbound)
}
if hook.lastInbound.ChatID != "direct" {
t.Fatalf("hook inbound chat ID = %q, want direct", hook.lastInbound.ChatID)
}
if hook.lastRoute == nil || hook.lastRoute.AgentID != "main" {
t.Fatalf("expected hook route context for /btw, got %+v", hook.lastRoute)
}
if hook.lastScope == nil || hook.lastScope.Values["sender"] != "hook-user" {
t.Fatalf("expected hook session scope for /btw, got %+v", hook.lastScope)
}
}
type toolHookProvider struct {
mu sync.Mutex
calls int

View file

@ -61,15 +61,19 @@ type AgentLoop struct {
pendingSkills sync.Map
mu sync.RWMutex
// Concurrent turn management (from HEAD)
activeTurnStates sync.Map // key: sessionKey (string), value: *turnState
subTurnCounter atomic.Int64 // Counter for generating unique SubTurn IDs
// workerSem limits concurrent turn processing workers.
workerSem chan struct{}
// activeTurnStates tracks active turns per session to prevent duplicates.
activeTurnStates sync.Map
subTurnCounter atomic.Int64
// Turn tracking (from Incoming)
turnSeq atomic.Uint64
activeRequests sync.WaitGroup
reloadFunc func() error
providerFactory func(*config.ModelConfig) (providers.LLMProvider, string, error)
}
// processOptions configures how a message is processed
@ -111,6 +115,7 @@ const (
toolLimitResponse = "I've reached `max_tool_iterations` without a final response. Increase `max_tool_iterations` in config.json if this task needs more tool steps."
handledToolResponseSummary = "Requested output delivered via tool attachment."
sessionKeyAgentPrefix = "agent:"
pendingTurnPrefix = "pending-"
metadataKeyMessageKind = "message_kind"
messageKindThought = "thought"
metadataKeyAccountID = "account_id"
@ -149,6 +154,13 @@ func NewAgentLoop(
}
eventBus := NewEventBus()
// Determine worker pool size from config (default: 1 = sequential)
workerPoolSize := cfg.Agents.Defaults.MaxParallelTurns
if workerPoolSize <= 0 {
workerPoolSize = 1
}
al := &AgentLoop{
bus: msgBus,
cfg: cfg,
@ -158,7 +170,9 @@ func NewAgentLoop(
fallback: fallbackChain,
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)),
workerSem: make(chan struct{}, workerPoolSize),
}
al.providerFactory = providers.CreateProviderFromConfig
al.hooks = NewHookManager(eventBus)
configureHookManagerFromConfig(al.hooks, cfg)
al.contextManager = al.resolveContextManager()
@ -194,7 +208,6 @@ func registerSharedTools(
if cfg.Tools.IsToolEnabled("web") {
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
Provider: cfg.Tools.Web.Provider,
BraveAPIKeys: cfg.Tools.Web.Brave.APIKeys.Values(),
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
@ -202,8 +215,6 @@ func registerSharedTools(
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
SogouMaxResults: cfg.Tools.Web.Sogou.MaxResults,
SogouEnabled: cfg.Tools.Web.Sogou.Enabled,
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
PerplexityAPIKeys: cfg.Tools.Web.Perplexity.APIKeys.Values(),
@ -475,214 +486,215 @@ func (al *AgentLoop) Run(ctx context.Context) error {
return nil
}
// Start a goroutine that drains the bus while processMessage is
// running. Only messages that resolve to the active turn scope are
// redirected into steering; other inbound messages are requeued.
drainCancel := func() {}
if activeScope, activeAgentID, ok := al.resolveSteeringTarget(msg); ok {
drainCtx, cancel := context.WithCancel(ctx)
drainCancel = cancel
go al.drainBusToSteering(drainCtx, activeScope, activeAgentID)
// Resolve the session key for this message
sessionKey, agentID, ok := al.resolveSteeringTarget(msg)
if !ok {
// Non-routable message (e.g., system) — process immediately.
// Note: system messages are processed in the main goroutine,
// so they block the receive loop but guarantee session serialization.
al.processMessageSync(ctx, msg)
continue
}
// Process message
func() {
// Atomically claim the session key with a unique placeholder sentinel
// to prevent a TOCTOU race where multiple messages for the same session
// pass the Load check before either registers.
// The placeholder ensures GetActiveTurnBySession() never returns nil
// during turn setup. Each placeholder has a unique turnID to prevent
// cross-worker cleanup issues.
placeholder := &turnState{
turnID: makePendingTurnID(sessionKey, al.turnSeq.Add(1)),
phase: TurnPhaseSetup,
}
if _, loaded := al.activeTurnStates.LoadOrStore(sessionKey, placeholder); loaded {
// Another turn is already active (or reserved) for this session — enqueue
if err := al.enqueueSteeringMessage(sessionKey, agentID, providers.Message{
Role: "user",
Content: msg.Content,
Media: append([]string(nil), msg.Media...),
}); err != nil {
logger.WarnCF("agent", "Failed to enqueue steering message",
map[string]any{
"error": err.Error(),
"channel": msg.Channel,
"chat_id": msg.ChatID,
"session_key": sessionKey,
})
}
continue
}
// Session claimed — spawn a worker goroutine that acquires a semaphore
// slot. The goroutine is spawned immediately so the main loop keeps
// draining the inbound channel. The goroutine blocks on the semaphore.
go func(m bus.InboundMessage) {
// Acquire semaphore slot (blocks if at capacity)
select {
case al.workerSem <- struct{}{}:
// Got slot, start worker
case <-ctx.Done():
// Context canceled while waiting for a slot — clean up the
// placeholder to prevent session-level deadlock.
al.activeTurnStates.Delete(sessionKey)
return
}
// Safety-net cleanup: if the placeholder was never replaced by a real
// turnState (e.g., error before runTurn), delete it here. When runTurn
// completes normally, clearActiveTurn deletes the real turnState and
// this becomes a no-op (the key is already gone).
defer func() {
if al.channelManager != nil {
al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID)
if actual, ok := al.activeTurnStates.Load(sessionKey); ok {
if ts, ok := actual.(*turnState); ok && strings.HasPrefix(ts.turnID, pendingTurnPrefix) {
// Placeholder still present — runTurn never replaced it.
al.activeTurnStates.Delete(sessionKey)
}
}
}()
// TODO: Re-enable media cleanup after inbound media is properly consumed by the agent.
// Currently disabled because files are deleted before the LLM can access their content.
// defer func() {
// if al.mediaStore != nil && msg.MediaScope != "" {
// if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil {
// logger.WarnCF("agent", "Failed to release media", map[string]any{
// "scope": msg.MediaScope,
// "error": releaseErr.Error(),
// })
// }
// }
// }()
drainCanceled := false
cancelDrain := func() {
if drainCanceled {
return
}
drainCancel()
drainCanceled = true
}
defer cancelDrain()
response, err := al.processMessage(ctx, msg)
if err != nil {
response = fmt.Sprintf("Error processing message: %v", err)
}
finalResponse := response
target, targetErr := al.buildContinuationTarget(msg)
if targetErr != nil {
logger.WarnCF("agent", "Failed to build steering continuation target",
map[string]any{
"channel": msg.Channel,
"error": targetErr.Error(),
})
return
}
if target == nil {
cancelDrain()
if finalResponse != "" {
al.PublishResponseIfNeeded(ctx, msg.Channel, msg.ChatID, finalResponse)
}
return
}
for al.pendingSteeringCountForScope(target.SessionKey) > 0 {
logger.InfoCF("agent", "Continuing queued steering after turn end",
map[string]any{
"channel": target.Channel,
"chat_id": target.ChatID,
"session_key": target.SessionKey,
"queue_depth": al.pendingSteeringCountForScope(target.SessionKey),
})
continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID)
if continueErr != nil {
logger.WarnCF("agent", "Failed to continue queued steering",
defer func() {
if r := recover(); r != nil {
logger.RecoverPanicNoExit(r)
logger.ErrorCF("agent", "Worker goroutine panicked",
map[string]any{
"channel": target.Channel,
"chat_id": target.ChatID,
"error": continueErr.Error(),
"session_key": sessionKey,
"channel": m.Channel,
"chat_id": m.ChatID,
"panic": fmt.Sprintf("%v", r),
})
return
}
if continued == "" {
return
}
}()
defer func() { <-al.workerSem }() // Release slot
finalResponse = continued
if al.channelManager != nil {
defer al.channelManager.InvokeTypingStop(m.Channel, m.ChatID)
}
cancelDrain()
al.runTurnWithSteering(ctx, m)
}(msg)
for al.pendingSteeringCountForScope(target.SessionKey) > 0 {
logger.InfoCF("agent", "Draining steering queued during turn shutdown",
map[string]any{
"channel": target.Channel,
"chat_id": target.ChatID,
"session_key": target.SessionKey,
"queue_depth": al.pendingSteeringCountForScope(target.SessionKey),
})
continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID)
if continueErr != nil {
logger.WarnCF("agent", "Failed to continue queued steering after shutdown drain",
map[string]any{
"channel": target.Channel,
"chat_id": target.ChatID,
"error": continueErr.Error(),
})
return
}
if continued == "" {
break
}
finalResponse = continued
}
if finalResponse != "" {
al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, finalResponse)
}
}()
// TODO: Re-enable media cleanup after inbound media is properly consumed by the agent.
// Currently disabled because files are deleted before the LLM can access their content.
// defer func() {
// if al.mediaStore != nil && msg.MediaScope != "" {
// if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil {
// logger.WarnCF("agent", "Failed to release media", map[string]any{
// "scope": msg.MediaScope,
// "error": releaseErr.Error(),
// })
// }
// }
// }()
}
}
}
// drainBusToSteering consumes inbound messages and redirects messages from the
// active scope into the steering queue. Messages from other scopes are requeued
// so they can be processed normally after the active turn. It drains all
// immediately available messages, blocking for the first one until ctx is done.
func (al *AgentLoop) drainBusToSteering(ctx context.Context, activeScope, activeAgentID string) {
blocking := true
var requeue []bus.InboundMessage
defer func() {
for _, msg := range requeue {
if err := al.requeueInboundMessage(msg); err != nil {
logger.WarnCF("agent", "Failed to flush requeued inbound message", map[string]any{
"error": err.Error(),
"channel": msg.Channel,
"sender_id": msg.SenderID,
})
}
// processMessageSync processes a message synchronously (for non-routable/system messages).
func (al *AgentLoop) processMessageSync(ctx context.Context, msg bus.InboundMessage) {
if al.channelManager != nil {
defer al.channelManager.InvokeTypingStop(msg.Channel, msg.ChatID)
}
response, err := al.processMessage(ctx, msg)
al.publishResponseOrError(ctx, msg.Channel, msg.ChatID, msg.SessionKey, response, err)
}
// runTurnWithSteering runs a complete turn for a message and drains its steering queue.
func (al *AgentLoop) runTurnWithSteering(ctx context.Context, initialMsg bus.InboundMessage) {
// Process the initial message
response, err := al.processMessage(ctx, initialMsg)
if err != nil {
if !al.maybePublishError(ctx, initialMsg.Channel, initialMsg.ChatID, initialMsg.SessionKey, err) {
return // context canceled
}
}()
response = ""
}
finalResponse := response
for {
var msg bus.InboundMessage
if blocking {
// Block waiting for the first available message or ctx cancellation.
select {
case <-ctx.Done():
return
case m, ok := <-al.bus.InboundChan():
if !ok {
return
}
msg = m
}
} else {
// Non-blocking: drain any remaining queued messages, return when empty.
select {
case m, ok := <-al.bus.InboundChan():
if !ok {
return
}
msg = m
default:
return
}
}
blocking = false
msgScope, _, scopeOK := al.resolveSteeringTarget(msg)
if !scopeOK || msgScope != activeScope {
requeue = append(requeue, msg)
continue
}
// Transcribe audio if needed before steering, so the agent sees text.
msg, _ = al.transcribeAudioInMessage(ctx, msg)
logger.InfoCF("agent", "Redirecting inbound message to steering queue",
// Build continuation target
target, targetErr := al.buildContinuationTarget(initialMsg)
if targetErr != nil {
logger.WarnCF("agent", "Failed to build steering continuation target",
map[string]any{
"channel": msg.Channel,
"sender_id": msg.SenderID,
"content_len": len(msg.Content),
"scope": activeScope,
"channel": initialMsg.Channel,
"error": targetErr.Error(),
})
return
}
if target == nil {
// System message or non-routable, response already published
return
}
// Drain steering queue using existing Continue mechanism
for al.pendingSteeringCountForScope(target.SessionKey) > 0 {
// Check for context cancellation between iterations
if ctx.Err() != nil {
return
}
logger.InfoCF("agent", "Continuing queued steering after turn end",
map[string]any{
"channel": target.Channel,
"chat_id": target.ChatID,
"session_key": target.SessionKey,
"queue_depth": al.pendingSteeringCountForScope(target.SessionKey),
})
if err := al.enqueueSteeringMessage(activeScope, activeAgentID, providers.Message{
Role: "user",
Content: msg.Content,
Media: append([]string(nil), msg.Media...),
}); err != nil {
logger.WarnCF("agent", "Failed to steer message, will be lost",
continued, continueErr := al.Continue(ctx, target.SessionKey, target.Channel, target.ChatID)
if continueErr != nil {
logger.WarnCF("agent", "Failed to continue queued steering",
map[string]any{
"error": err.Error(),
"channel": msg.Channel,
"channel": target.Channel,
"chat_id": target.ChatID,
"error": continueErr.Error(),
})
break
}
if continued == "" {
break
}
finalResponse = continued
}
// Publish final response
if finalResponse != "" {
al.PublishResponseIfNeeded(ctx, target.Channel, target.ChatID, target.SessionKey, finalResponse)
}
}
// maybePublishError publishes an error response unless the error is context.Canceled.
// Returns true if processing should continue (non-cancellation error or no error),
// false if context was canceled and the caller should return.
func (al *AgentLoop) maybePublishError(ctx context.Context, channel, chatID, sessionKey string, err error) bool {
if errors.Is(err, context.Canceled) {
return false
}
al.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, fmt.Sprintf("Error processing message: %v", err))
return true
}
// publishResponseOrError publishes the response, or an error message if processing failed.
func (al *AgentLoop) publishResponseOrError(
ctx context.Context,
channel, chatID, sessionKey string,
response string,
err error,
) {
if err != nil {
if !al.maybePublishError(ctx, channel, chatID, sessionKey, err) {
return
}
response = ""
}
al.PublishResponseIfNeeded(ctx, channel, chatID, sessionKey, response)
}
func (al *AgentLoop) Stop() {
al.running.Store(false)
}
func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string) {
func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatID, sessionKey, response string) {
if response == "" {
return
}
@ -692,7 +704,7 @@ func (al *AgentLoop) PublishResponseIfNeeded(ctx context.Context, channel, chatI
if defaultAgent != nil {
if tool, ok := defaultAgent.Tools.Get("message"); ok {
if mt, ok := tool.(*tools.MessageTool); ok {
alreadySentToSameChat = mt.HasSentTo(channel, chatID)
alreadySentToSameChat = mt.HasSentTo(sessionKey, channel, chatID)
}
}
}
@ -1590,13 +1602,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return "", routeErr
}
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
if tool, ok := agent.Tools.Get("message"); ok {
if resetter, ok := tool.(interface{ ResetSentInRound() }); ok {
resetter.ResetSentInRound()
}
}
allocation := al.allocateRouteSession(route, msg)
// Resolve session key from the route allocation, while preserving explicit
@ -1604,6 +1609,13 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
scopeKey := resolveScopeKey(allocation.SessionKey, msg.SessionKey)
sessionKey := scopeKey
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
if tool, ok := agent.Tools.Get("message"); ok {
if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok {
resetter.ResetSentInRound(sessionKey)
}
}
logger.InfoCF("agent", "Routed message",
map[string]any{
"agent_id": agent.ID,
@ -1741,15 +1753,6 @@ func (al *AgentLoop) resolveSteeringTarget(msg bus.InboundMessage) (string, stri
return resolveScopeKey(allocation.SessionKey, msg.SessionKey), agent.ID, true
}
func (al *AgentLoop) requeueInboundMessage(msg bus.InboundMessage) error {
if al.bus == nil {
return nil
}
pubCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
return al.bus.PublishInbound(pubCtx, msg)
}
func (al *AgentLoop) processSystemMessage(
ctx context.Context,
msg bus.InboundMessage,
@ -2381,8 +2384,6 @@ turnLoop:
var response *providers.LLMResponse
var err error
maxRetries := 2
callHasMedia := messagesContainMedia(callMessages)
didStripMedia := false
for retry := 0; retry <= maxRetries; retry++ {
response, err = callLLM(callMessages, providerToolDefs)
if err == nil {
@ -2393,43 +2394,34 @@ turnLoop:
return al.abortTurn(ts)
}
// If the provider/model doesn't support multimodal inputs, retry once with media stripped
// so the session doesn't get "stuck" after a user sends an image.
if callHasMedia && !didStripMedia && isVisionUnsupportedError(err) {
didStripMedia = true
if !ts.opts.NoHistory {
history = ts.agent.Sessions.GetHistory(ts.sessionKey)
ts.agent.Sessions.SetHistory(ts.sessionKey, stripMessageMedia(history))
// Keep persistedMessages aligned so abort restore-point trimming remains correct.
ts.mu.Lock()
for i := range ts.persistedMessages {
ts.persistedMessages[i].Media = nil
}
ts.mu.Unlock()
ts.refreshRestorePointFromSession(ts.agent)
}
messages = stripMessageMedia(messages)
callMessages = stripMessageMedia(callMessages)
callHasMedia = false
// Retry without media if vision is unsupported
if hasMediaRefs(callMessages) && isVisionUnsupportedError(err) && retry < maxRetries {
al.emitEvent(
EventKindLLMRetry,
ts.eventMeta("runTurn", "turn.llm.retry"),
LLMRetryPayload{
Attempt: 1,
MaxRetries: 1,
Attempt: retry + 1,
MaxRetries: maxRetries,
Reason: "vision_unsupported",
Error: err.Error(),
Backoff: 0,
},
)
response, err = callLLM(callMessages, providerToolDefs)
if err == nil {
break
logger.WarnCF("agent", "Vision unsupported, retrying without media", map[string]any{
"error": err.Error(),
"retry": retry,
})
callMessages = stripMessageMedia(callMessages)
// Also strip media from session history to prevent future errors
if !ts.opts.NoHistory {
history = stripMessageMedia(history)
ts.agent.Sessions.SetHistory(ts.sessionKey, history)
for i := range ts.persistedMessages {
ts.persistedMessages[i].Media = nil
}
ts.refreshRestorePointFromSession(ts.agent)
}
continue
}
errMsg := strings.ToLower(err.Error())
@ -3921,10 +3913,391 @@ func (al *AgentLoop) buildCommandsRuntime(
}
return al.contextManager.Clear(ctx, opts.SessionKey)
}
rt.AskSideQuestion = func(ctx context.Context, question string) (string, error) {
return al.askSideQuestion(ctx, agent, opts, question)
}
}
return rt
}
// askSideQuestion handles /btw commands by creating an isolated provider instance
// that doesn't share state with the main conversation provider.
func (al *AgentLoop) askSideQuestion(
ctx context.Context,
agent *AgentInstance,
opts *processOptions,
question string,
) (string, error) {
if agent == nil {
return "", fmt.Errorf("askSideQuestion: no agent available for /btw")
}
question = strings.TrimSpace(question)
if question == "" {
return "", fmt.Errorf("askSideQuestion: %w", fmt.Errorf("Usage: /btw <question>"))
}
if opts != nil {
normalizeProcessOptionsInPlace(opts)
}
var media []string
var channel, chatID, senderID, senderDisplayName string
if opts != nil {
media = opts.Media
channel = opts.Channel
chatID = opts.ChatID
senderID = opts.SenderID
senderDisplayName = opts.SenderDisplayName
}
// Build messages with context but WITHOUT adding to session history
var history []providers.Message
var summary string
if opts != nil && !opts.NoHistory {
if resp, err := al.contextManager.Assemble(ctx, &AssembleRequest{
SessionKey: opts.SessionKey,
Budget: agent.ContextWindow,
MaxTokens: agent.MaxTokens,
}); err == nil && resp != nil {
history = resp.History
summary = resp.Summary
}
}
messages := agent.ContextBuilder.BuildMessages(
history,
summary,
question,
media,
channel,
chatID,
senderID,
senderDisplayName,
)
maxMediaSize := al.GetConfig().Agents.Defaults.GetMaxMediaSize()
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
activeCandidates, activeModel, usedLight := al.selectCandidates(agent, question, messages)
selectedModelName := sideQuestionModelName(agent, usedLight)
llmOpts := map[string]any{
"max_tokens": agent.MaxTokens,
"temperature": agent.Temperature,
"prompt_cache_key": agent.ID + ":btw",
}
hookModelChanged := false
callProvider := func(
ctx context.Context,
candidate providers.FallbackCandidate,
model string,
forceModel bool,
callMessages []providers.Message,
) (*providers.LLMResponse, error) {
provider, providerModel, cleanup, err := al.isolatedSideQuestionProvider(agent, selectedModelName, candidate)
if err != nil {
return nil, err
}
defer cleanup()
if !forceModel || strings.TrimSpace(model) == "" {
model = providerModel
}
callOpts := llmOpts
if _, exists := callOpts["thinking_level"]; !exists && agent.ThinkingLevel != ThinkingOff {
if tc, ok := provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() {
callOpts = shallowCloneLLMOptions(llmOpts)
callOpts["thinking_level"] = string(agent.ThinkingLevel)
}
}
return provider.Chat(ctx, callMessages, nil, model, callOpts)
}
turnCtx := newTurnContext(nil, nil, nil)
if opts != nil {
turnCtx = newTurnContext(opts.Dispatch.InboundContext, opts.Dispatch.RouteResult, opts.Dispatch.SessionScope)
}
llmModel := activeModel
if al.hooks != nil {
llmReq, decision := al.hooks.BeforeLLM(ctx, &LLMHookRequest{
Meta: EventMeta{
Source: "askSideQuestion",
TracePath: "turn.llm.request",
turnContext: cloneTurnContext(turnCtx),
},
Context: cloneTurnContext(turnCtx),
Model: llmModel,
Messages: messages,
Tools: nil,
Options: llmOpts,
GracefulTerminal: false,
})
switch decision.normalizedAction() {
case HookActionContinue, HookActionModify:
if llmReq != nil {
if strings.TrimSpace(llmReq.Model) != "" && llmReq.Model != llmModel {
hookModelChanged = true
}
llmModel = llmReq.Model
messages = llmReq.Messages
llmOpts = llmReq.Options
}
case HookActionAbortTurn:
reason := decision.Reason
if reason == "" {
reason = "hook requested turn abort"
}
return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason)
case HookActionHardAbort:
reason := decision.Reason
if reason == "" {
reason = "hook requested turn abort"
}
return "", fmt.Errorf("hook aborted turn during before_llm: %s", reason)
}
}
if hookModelChanged {
// Hook-selected models must not continue through the pre-hook fallback
// candidate list, otherwise fallback execution would call the original
// candidate model and silently ignore the hook decision.
activeCandidates = nil
}
callSideLLM := func(callMessages []providers.Message) (*providers.LLMResponse, error) {
if len(activeCandidates) > 1 && al.fallback != nil {
fbResult, err := al.fallback.Execute(
ctx,
activeCandidates,
func(ctx context.Context, providerName, model string) (*providers.LLMResponse, error) {
candidate := providers.FallbackCandidate{Provider: providerName, Model: model}
for _, activeCandidate := range activeCandidates {
if activeCandidate.Provider == providerName && activeCandidate.Model == model {
candidate = activeCandidate
break
}
}
return callProvider(ctx, candidate, model, false, callMessages)
},
)
if err != nil {
return nil, err
}
return fbResult.Response, nil
}
var candidate providers.FallbackCandidate
if len(activeCandidates) > 0 {
candidate = activeCandidates[0]
}
return callProvider(ctx, candidate, llmModel, hookModelChanged, callMessages)
}
// Retry without media if vision is unsupported
// Note: Vision retry is only applied to the initial call. If fallback chain
// is used, vision errors from fallback providers will not trigger retry.
var resp *providers.LLMResponse
var err error
resp, err = callSideLLM(messages)
if err != nil && hasMediaRefs(messages) && isVisionUnsupportedError(err) {
al.emitEvent(
EventKindLLMRetry,
EventMeta{
Source: "askSideQuestion",
TracePath: "turn.llm.retry",
turnContext: cloneTurnContext(turnCtx),
},
LLMRetryPayload{
Attempt: 1,
MaxRetries: 1,
Reason: "vision_unsupported",
Error: err.Error(),
Backoff: 0,
},
)
messagesWithoutMedia := stripMessageMedia(messages)
resp, err = callSideLLM(messagesWithoutMedia)
}
if err != nil {
return "", err
}
if resp == nil {
return "", nil
}
// Apply after_llm hooks
if al.hooks != nil {
llmResp, decision := al.hooks.AfterLLM(ctx, &LLMHookResponse{
Meta: EventMeta{
Source: "askSideQuestion",
TracePath: "turn.llm.response",
turnContext: cloneTurnContext(turnCtx),
},
Context: cloneTurnContext(turnCtx),
Model: llmModel,
Response: resp,
})
switch decision.normalizedAction() {
case HookActionContinue, HookActionModify:
if llmResp != nil && llmResp.Response != nil {
resp = llmResp.Response
}
case HookActionAbortTurn, HookActionHardAbort:
reason := decision.Reason
if reason == "" {
reason = "hook requested turn abort"
}
return "", fmt.Errorf("hook aborted turn during after_llm: %s", reason)
}
}
return sideQuestionResponseContent(resp), nil
}
func sideQuestionResponseContent(response *providers.LLMResponse) string {
if response == nil {
return ""
}
if response.Content != "" {
return response.Content
}
return response.ReasoningContent
}
// shallowCloneLLMOptions creates a shallow copy of LLM options map.
// Note: This is a shallow copy - nested maps/slices are shared.
func shallowCloneLLMOptions(opts map[string]any) map[string]any {
clone := make(map[string]any, len(opts))
for k, v := range opts {
clone[k] = v
}
return clone
}
// hasMediaRefs checks if any message has media references.
func hasMediaRefs(messages []providers.Message) bool {
for _, msg := range messages {
if len(msg.Media) > 0 {
return true
}
}
return false
}
// isolatedSideQuestionProvider creates a separate provider instance for /btw commands
// to avoid sharing state with the main conversation provider.
func (al *AgentLoop) isolatedSideQuestionProvider(
agent *AgentInstance,
baseModelName string,
candidate providers.FallbackCandidate,
) (providers.LLMProvider, string, func(), error) {
if agent == nil {
return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: no agent available for /btw")
}
modelCfg, err := al.sideQuestionModelConfig(agent, baseModelName, candidate)
if err != nil {
return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err)
}
factory := al.providerFactory
if factory == nil {
factory = providers.CreateProviderFromConfig
}
provider, modelID, err := factory(modelCfg)
if err != nil {
return nil, "", func() {}, fmt.Errorf("isolatedSideQuestionProvider: %w", err)
}
cleanup := func() {
closeProviderIfStateful(provider)
}
return provider, modelID, cleanup, nil
}
// sideQuestionModelConfig resolves the model config for side questions.
func (al *AgentLoop) sideQuestionModelConfig(
agent *AgentInstance,
baseModelName string,
candidate providers.FallbackCandidate,
) (*config.ModelConfig, error) {
if agent == nil {
return nil, fmt.Errorf("sideQuestionModelConfig: no agent available for /btw")
}
// If candidate has an identity key, use that
if name := modelNameFromIdentityKey(candidate.IdentityKey); name != "" {
modelCfg, err := resolvedModelConfig(al.GetConfig(), name, agent.Workspace)
if err == nil {
return modelCfg, nil
}
// Fallback: create a minimal config if lookup fails
}
// Otherwise, clean up the base model name and use it
baseModelName = strings.TrimSpace(baseModelName)
modelCfg, err := resolvedModelConfig(al.GetConfig(), baseModelName, agent.Workspace)
if err != nil {
// Fallback: create a minimal config for test scenarios
model := strings.TrimSpace(baseModelName)
if candidate.Model != "" {
model = candidate.Model
}
if candidate.Provider != "" && candidate.Model != "" {
model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model
} else {
model = ensureProtocolModel(model)
}
return &config.ModelConfig{
ModelName: baseModelName,
Model: model,
Workspace: agent.Workspace,
}, nil
}
// If candidate specifies a different provider/model, override
clone := *modelCfg
if candidate.Provider != "" && candidate.Model != "" {
clone.Model = providers.NormalizeProvider(candidate.Provider) + "/" + candidate.Model
}
return &clone, nil
}
// sideQuestionModelName determines which model name to use for side questions.
func sideQuestionModelName(agent *AgentInstance, usedLight bool) string {
if usedLight && len(agent.LightCandidates) > 0 {
// Use the first light candidate's model
return agent.LightCandidates[0].Model
}
return agent.Model
}
// modelNameFromIdentityKey extracts the model name from an identity key.
func modelNameFromIdentityKey(identityKey string) string {
if identityKey == "" {
return ""
}
parts := strings.SplitN(identityKey, "/", 2)
if len(parts) == 2 {
return parts[1]
}
return identityKey
}
// closeProviderIfStateful closes a provider if it implements StatefulProvider.
func closeProviderIfStateful(provider providers.LLMProvider) {
if stateful, ok := provider.(providers.StatefulProvider); ok {
stateful.Close()
}
}
// makePendingTurnID generates a unique turn ID for placeholder turns.
// Format: "pending-{sessionKey}-{sequence}"
func makePendingTurnID(sessionKey string, seq uint64) string {
return pendingTurnPrefix + sessionKey + "-" + fmt.Sprintf("%d", seq)
}
func commandsUnavailableSkillMessage() string {
return "Skill selection is unavailable in the current context."
}

View file

@ -9,8 +9,10 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"sync"
"testing"
"time"
@ -80,6 +82,7 @@ func newStartedTestChannelManager(
type recordingProvider struct {
lastMessages []providers.Message
lastModel string
}
func (r *recordingProvider) Chat(
@ -90,6 +93,7 @@ func (r *recordingProvider) Chat(
opts map[string]any,
) (*providers.LLMResponse, error) {
r.lastMessages = append([]providers.Message(nil), messages...)
r.lastModel = model
return &providers.LLMResponse{
Content: "Mock response",
ToolCalls: []providers.ToolCall{},
@ -100,6 +104,38 @@ func (r *recordingProvider) GetDefaultModel() string {
return "mock-model"
}
type modelRewriteHook struct {
model string
}
func (h modelRewriteHook) BeforeLLM(
ctx context.Context,
req *LLMHookRequest,
) (*LLMHookRequest, HookDecision, error) {
next := req.Clone()
next.Model = h.model
return next, HookDecision{Action: HookActionModify}, nil
}
func (h modelRewriteHook) AfterLLM(
ctx context.Context,
resp *LLMHookResponse,
) (*LLMHookResponse, HookDecision, error) {
return resp.Clone(), HookDecision{Action: HookActionContinue}, nil
}
func useTestSideQuestionProvider(al *AgentLoop, provider providers.LLMProvider) {
al.providerFactory = func(mc *config.ModelConfig) (providers.LLMProvider, string, error) {
model := provider.GetDefaultModel()
if mc != nil {
if _, modelID := providers.ExtractProtocol(mc.Model); modelID != "" {
model = modelID
}
}
return provider, model, nil
}
}
func newTestAgentLoop(
t *testing.T,
) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) {
@ -235,6 +271,330 @@ func TestProcessMessage_UseCommandLoadsRequestedSkill(t *testing.T) {
}
}
func TestProcessMessage_BtwCommandRunsWithoutPersistingHistory(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
// Add model list so isolated provider can resolve the model
ModelList: []*config.ModelConfig{
{ModelName: "test-model", Model: "openai/test-model"},
},
}
msgBus := bus.NewMessageBus()
provider := &recordingProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
useTestSideQuestionProvider(al, provider)
defaultAgent := al.GetRegistry().GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("expected default agent")
}
msg := bus.InboundMessage{
Channel: "telegram",
SenderID: "telegram:123",
ChatID: "chat-1",
Content: "/btw explain side effects",
}
route, _, err := al.resolveMessageRoute(msg)
if err != nil {
t.Fatalf("resolveMessageRoute() error = %v", err)
}
allocation := al.allocateRouteSession(route, msg)
sessionKey := resolveScopeKey(allocation.SessionKey, msg.SessionKey)
initialHistory := []providers.Message{
{Role: "user", Content: "We decided to avoid global state."},
{Role: "assistant", Content: "Right, keep it request-scoped."},
}
defaultAgent.Sessions.SetHistory(sessionKey, initialHistory)
defaultAgent.Sessions.SetSummary(sessionKey, "The team decided to keep state request-scoped.")
response, err := al.processMessage(context.Background(), msg)
if err != nil {
t.Fatalf("processMessage() error = %v", err)
}
if response != "Mock response" {
t.Fatalf("processMessage() response = %q, want %q", response, "Mock response")
}
if len(provider.lastMessages) == 0 {
t.Fatal("provider did not receive any messages")
}
if len(provider.lastMessages) != 4 {
t.Fatalf("provider messages len = %d, want 4 (system + prior history + user)", len(provider.lastMessages))
}
if !reflect.DeepEqual(provider.lastMessages[1:3], initialHistory) {
t.Fatalf("provider history = %#v, want %#v", provider.lastMessages[1:3], initialHistory)
}
lastMessage := provider.lastMessages[len(provider.lastMessages)-1]
if lastMessage.Role != "user" || lastMessage.Content != "explain side effects" {
t.Fatalf("last provider message = %+v, want stripped /btw question", lastMessage)
}
history := al.GetRegistry().GetDefaultAgent().Sessions.GetHistory(sessionKey)
if !reflect.DeepEqual(history, initialHistory) {
t.Fatalf("session history = %#v, want %#v", history, initialHistory)
}
}
func TestProcessMessage_BtwCommandIncludesRequestContextAndMedia(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 := &recordingProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
useTestSideQuestionProvider(al, provider)
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
Channel: "discord",
SenderID: "discord:123",
Sender: bus.SenderInfo{
DisplayName: "Alice",
},
ChatID: "group-1",
Content: "/btw describe this image",
Media: []string{"media://image-1"},
}))
if err != nil {
t.Fatalf("processMessage() error = %v", err)
}
if response != "Mock response" {
t.Fatalf("processMessage() response = %q, want %q", response, "Mock response")
}
if len(provider.lastMessages) == 0 {
t.Fatal("provider did not receive any messages")
}
systemPrompt := provider.lastMessages[0].Content
if !strings.Contains(systemPrompt, "## Current Session\nChannel: discord\nChat ID: group-1") {
t.Fatalf("system prompt missing current session context:\n%s", systemPrompt)
}
if !strings.Contains(systemPrompt, "## Current Sender\nCurrent sender: Alice (ID: discord:123)") {
t.Fatalf("system prompt missing current sender context:\n%s", systemPrompt)
}
lastMessage := provider.lastMessages[len(provider.lastMessages)-1]
if lastMessage.Role != "user" || lastMessage.Content != "describe this image" {
t.Fatalf("last provider message = %+v, want stripped /btw question", lastMessage)
}
if !reflect.DeepEqual(lastMessage.Media, []string{"media://image-1"}) {
t.Fatalf("last provider media = %#v, want media ref", lastMessage.Media)
}
}
func TestProcessMessage_BtwCommandUsesIsolatedProvider(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
// Add model list so isolated provider can resolve the model
ModelList: []*config.ModelConfig{
{ModelName: "test-model", Model: "openai/test-model"},
},
}
msgBus := bus.NewMessageBus()
provider := &recordingProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
useTestSideQuestionProvider(al, provider)
defaultAgent := al.GetRegistry().GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("expected default agent")
}
// Set up initial history for the main session
mainSessionKey := "telegram:123:chat-1"
initialHistory := []providers.Message{
{Role: "user", Content: "We decided to avoid global state."},
{Role: "assistant", Content: "Right, keep it request-scoped."},
}
defaultAgent.Sessions.SetHistory(mainSessionKey, initialHistory)
// Process a /btw command
response, err := al.processMessage(context.Background(), bus.InboundMessage{
Channel: "telegram",
SenderID: "telegram:123",
ChatID: "chat-1",
SessionKey: mainSessionKey,
Content: "/btw explain isolation",
})
if err != nil {
t.Fatalf("processMessage() error = %v", err)
}
if response != "Mock response" {
t.Fatalf("processMessage() response = %q, want %q", response, "Mock response")
}
// Verify the provider received the side question
if len(provider.lastMessages) == 0 {
t.Fatal("provider did not receive any messages for /btw command")
}
// Verify the question was stripped of /btw prefix
lastMessage := provider.lastMessages[len(provider.lastMessages)-1]
if lastMessage.Role != "user" || lastMessage.Content != "explain isolation" {
t.Fatalf("last provider message = %+v, want stripped /btw question", lastMessage)
}
// Verify main session history was NOT modified
currentHistory := defaultAgent.Sessions.GetHistory(mainSessionKey)
if !reflect.DeepEqual(currentHistory, initialHistory) {
t.Fatalf("main session history was modified:\ngot %#v\nwant %#v", currentHistory, initialHistory)
}
}
func TestProcessMessage_BtwCommandRetriesWithoutMediaOnVisionUnsupported(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
// Add model list so isolated provider can resolve the model
ModelList: []*config.ModelConfig{
{ModelName: "test-model", Model: "openai/test-model"},
},
}
msgBus := bus.NewMessageBus()
provider := &visionUnsupportedMediaProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
useTestSideQuestionProvider(al, provider)
response, err := al.processMessage(context.Background(), testInboundMessage(bus.InboundMessage{
Channel: "telegram",
SenderID: "telegram:123",
ChatID: "chat-1",
Content: "/btw describe this image",
Media: []string{"data:image/png;base64,abc123"},
}))
if err != nil {
t.Fatalf("processMessage() error = %v", err)
}
if response != "ok" {
t.Fatalf("processMessage() response = %q, want %q", response, "ok")
}
// Note: With isolated providers, each /btw creates a new provider instance,
// so we can't track calls across retries in the same way.
// The retry logic happens within askSideQuestion, creating separate isolated providers.
// For now, we just verify the command succeeds.
if provider.calls < 1 {
t.Fatalf("provider was not called for /btw command")
}
}
func TestProcessMessage_BtwCommandUsesProviderFactoryModel(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "lb-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
ModelList: []*config.ModelConfig{
{ModelName: "lb-model", Model: "openai/lb-model-a"},
{ModelName: "lb-model", Model: "openai/lb-model-b"},
},
}
msgBus := bus.NewMessageBus()
provider := &recordingProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
useTestSideQuestionProvider(al, provider)
response, err := al.processMessage(context.Background(), bus.InboundMessage{
Channel: "telegram",
SenderID: "telegram:123",
ChatID: "chat-1",
Content: "/btw explain load balancing",
})
if err != nil {
t.Fatalf("processMessage() error = %v", err)
}
if response != "Mock response" {
t.Fatalf("processMessage() response = %q, want %q", response, "Mock response")
}
// Verify that /btw used the configured model from ModelList
// The provider should have been called with one of the lb-model variants
if provider.lastModel == "" {
t.Fatal("provider was not called for /btw command")
}
if !strings.HasPrefix(provider.lastModel, "lb-model") {
t.Fatalf("/btw used model %q, expected lb-model variant", provider.lastModel)
}
}
func TestProcessMessage_BtwCommandHookModelBypassesFallbackCandidates(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "primary-model",
ModelFallbacks: []string{"fallback-model"},
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
provider := &recordingProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
useTestSideQuestionProvider(al, provider)
if err := al.MountHook(NamedHook("rewrite-model", modelRewriteHook{model: "hook-model"})); err != nil {
t.Fatalf("MountHook failed: %v", err)
}
response, err := al.processMessage(context.Background(), bus.InboundMessage{
Channel: "telegram",
SenderID: "telegram:123",
ChatID: "chat-1",
Content: "/btw explain hook routing",
})
if err != nil {
t.Fatalf("processMessage() error = %v", err)
}
if response != "Mock response" {
t.Fatalf("processMessage() response = %q, want %q", response, "Mock response")
}
if provider.lastModel != "hook-model" {
t.Fatalf("/btw model = %q, want hook-selected model", provider.lastModel)
}
}
func TestHandleCommand_UseCommandRejectsUnknownSkill(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
@ -3958,3 +4318,258 @@ func TestProcessMessage_ContextOverflow_AnthropicStyle(t *testing.T) {
t.Fatalf("expected 2 calls for retry, got %d", provider.calls)
}
}
func TestParallelMessageProcessing_DifferentSessionsProcessedConcurrently(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)
// Track concurrent executions using a unique ID per turn
var mu sync.Mutex
activeTurns := make(map[string]bool)
maxConcurrent := 0
turnCounter := 0
var wg sync.WaitGroup
wg.Add(3) // Wait for 3 turns to complete
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
MaxParallelTurns: 3, // Allow up to 3 concurrent turns
},
},
Session: config.SessionConfig{
Dimensions: []string{"chat"},
},
}
msgBus := bus.NewMessageBus()
defer msgBus.Close()
// Create a slow mock provider that tracks concurrency
provider := &concurrentMockProvider{
responseFunc: func(callID int) string {
mu.Lock()
turnCounter++
turnID := fmt.Sprintf("turn-%d", turnCounter)
activeTurns[turnID] = true
currentActive := len(activeTurns)
if currentActive > maxConcurrent {
maxConcurrent = currentActive
}
mu.Unlock()
// Simulate some processing time
time.Sleep(100 * time.Millisecond)
mu.Lock()
delete(activeTurns, turnID)
mu.Unlock()
wg.Done()
return fmt.Sprintf("Response %s", turnID)
},
}
al := NewAgentLoop(cfg, msgBus, provider)
defer al.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Start the agent loop
go func() {
if err := al.Run(ctx); err != nil {
t.Logf("Agent loop error: %v", err)
}
}()
// Give the loop time to start
time.Sleep(50 * time.Millisecond)
// Send 3 messages from different sessions
sessions := []string{"user1", "user2", "user3"}
for i, session := range sessions {
msg := bus.InboundMessage{
Context: bus.InboundContext{
Channel: "telegram",
ChatID: fmt.Sprintf("chat%d", i),
ChatType: "direct",
SenderID: session,
},
Channel: "telegram",
ChatID: fmt.Sprintf("chat%d", i),
SenderID: session,
Content: fmt.Sprintf("Hello from %s", session),
}
if err := msgBus.PublishInbound(context.Background(), msg); err != nil {
t.Fatalf("PublishInbound failed: %v", err)
}
}
// Wait for all turns to complete with timeout
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
// All turns completed successfully
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for turns to complete")
}
// Verify that we had concurrent executions
mu.Lock()
defer mu.Unlock()
if maxConcurrent < 2 {
t.Errorf("Expected at least 2 concurrent executions, got max %d", maxConcurrent)
}
t.Logf("Maximum concurrent executions: %d", maxConcurrent)
}
func TestParallelMessageProcessing_SameSessionProcessedSequentially(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)
var mu sync.Mutex
turnIDs := make(map[string]bool)
var wg sync.WaitGroup
wg.Add(1) // Only 1 turn should be created for same session
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
MaxParallelTurns: 3,
},
},
Session: config.SessionConfig{
Dimensions: []string{"chat"},
},
}
msgBus := bus.NewMessageBus()
defer msgBus.Close()
al := NewAgentLoop(cfg, msgBus, &concurrentMockProvider{
responseFunc: func(callID int) string {
wg.Done()
return "ok"
},
})
defer al.Close()
sub := al.SubscribeEvents(64)
go func() {
for evt := range sub.C {
if evt.Kind == EventKindTurnStart {
mu.Lock()
turnIDs[evt.Meta.TurnID] = true
mu.Unlock()
}
}
}()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
if err := al.Run(ctx); err != nil {
t.Logf("Agent loop error: %v", err)
}
}()
time.Sleep(50 * time.Millisecond)
// Send 3 messages from the SAME session - only one turn should be created;
// subsequent messages should be enqueued to the steering queue and processed
// within the same turn (not as separate concurrent turns).
for i := 0; i < 3; i++ {
msg := bus.InboundMessage{
Context: bus.InboundContext{
Channel: "telegram",
ChatID: "chat1",
ChatType: "direct",
SenderID: "user1",
},
Channel: "telegram",
SenderID: "user1",
ChatID: "chat1",
Content: fmt.Sprintf("Message %d", i+1),
}
if err := msgBus.PublishInbound(context.Background(), msg); err != nil {
t.Fatalf("PublishInbound failed: %v", err)
}
}
// Wait for turn to complete with timeout
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
// Turn completed successfully
case <-time.After(5 * time.Second):
t.Fatal("timeout waiting for turn to complete")
}
mu.Lock()
defer mu.Unlock()
// Only 1 turn ID should have been created — proving messages were
// serialized into a single turn rather than spawning concurrent turns.
if len(turnIDs) != 1 {
t.Errorf("Expected 1 turn (others queued to steering), got %d: %v", len(turnIDs), turnIDs)
}
}
// concurrentMockProvider is a mock provider that allows tracking concurrency
type concurrentMockProvider struct {
responseFunc func(callID int) string
}
func (p *concurrentMockProvider) Chat(
ctx context.Context,
messages []providers.Message,
tools []providers.ToolDefinition,
model string,
opts map[string]any,
) (*providers.LLMResponse, error) {
// Use an atomic counter to assign unique call IDs for concurrency tracking.
// This avoids relying on sessionKey derivation from message content, which
// is not deterministic across concurrent calls.
response := "Mock response"
if p.responseFunc != nil {
response = p.responseFunc(len(messages))
}
return &providers.LLMResponse{
Content: response,
ToolCalls: []providers.ToolCall{},
}, nil
}
func (p *concurrentMockProvider) GetDefaultModel() string {
return "test-model"
}

View file

@ -348,29 +348,46 @@ func (al *AgentLoop) agentForSession(sessionKey string) *AgentInstance {
//
// If no steering messages are pending, it returns an empty string.
func (al *AgentLoop) Continue(ctx context.Context, sessionKey, channel, chatID string) (string, error) {
if active := al.GetActiveTurn(); active != nil {
return "", fmt.Errorf("turn %s is still active", active.TurnID)
// Claim the session with a unique placeholder to prevent a TOCTOU race where two
// concurrent Continue calls for the same session both pass the active-turn
// check and create parallel turns. The placeholder is replaced by the real
// turnState inside continueWithSteeringMessages → runAgentLoop → registerActiveTurn.
placeholder := &turnState{
turnID: "pending-continue-" + sessionKey + "-" + fmt.Sprintf("%d", al.turnSeq.Add(1)),
phase: TurnPhaseSetup,
}
if _, loaded := al.activeTurnStates.LoadOrStore(sessionKey, placeholder); loaded {
if active := al.GetActiveTurnBySession(sessionKey); active != nil {
return "", fmt.Errorf("turn %s is still active for session %q", active.TurnID, sessionKey)
}
// Another Continue just claimed the slot; let it handle the steering.
return "", nil
}
if err := al.ensureHooksInitialized(ctx); err != nil {
al.activeTurnStates.Delete(sessionKey)
return "", err
}
if err := al.ensureMCPInitialized(ctx); err != nil {
al.activeTurnStates.Delete(sessionKey)
return "", err
}
steeringMsgs := al.dequeueSteeringMessagesForScopeWithFallback(sessionKey)
if len(steeringMsgs) == 0 {
al.activeTurnStates.Delete(sessionKey)
return "", nil
}
agent := al.agentForSession(sessionKey)
if agent == nil {
al.activeTurnStates.Delete(sessionKey)
return "", fmt.Errorf("no agent available for session %q", sessionKey)
}
if tool, ok := agent.Tools.Get("message"); ok {
if resetter, ok := tool.(interface{ ResetSentInRound() }); ok {
resetter.ResetSentInRound()
if resetter, ok := tool.(interface{ ResetSentInRound(sessionKey string) }); ok {
resetter.ResetSentInRound(sessionKey)
}
}
@ -403,11 +420,18 @@ func (al *AgentLoop) InterruptGraceful(hint string) error {
return nil
}
// InterruptHard aborts an arbitrary active turn. In parallel mode this may
// target the wrong session. Prefer HardAbort(sessionKey) instead.
//
// Deprecated: Use HardAbort(sessionKey) for session-safe aborts.
func (al *AgentLoop) InterruptHard() error {
ts := al.getAnyActiveTurnState()
if ts == nil {
return fmt.Errorf("no active turn")
}
if strings.HasPrefix(ts.turnID, "pending-") {
return fmt.Errorf("turn is still initializing for session %s", ts.sessionKey)
}
if !ts.requestHardAbort() {
return fmt.Errorf("turn %s is already aborting", ts.turnID)
}
@ -474,6 +498,10 @@ func (al *AgentLoop) HardAbort(sessionKey string) error {
return fmt.Errorf("invalid turn state type for session %s", sessionKey)
}
if strings.HasPrefix(ts.turnID, "pending-") {
return fmt.Errorf("turn is still initializing for session %s", sessionKey)
}
logger.InfoCF("agent", "Hard abort triggered", map[string]any{
"session_key": sessionKey,
"turn_id": ts.turnID,

View file

@ -341,95 +341,6 @@ func TestAgentLoop_Continue_WithMessages(t *testing.T) {
}
}
func TestDrainBusToSteering_RequeuesDifferentScopeMessage(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
Session: config.SessionConfig{
Dimensions: []string{"sender"},
},
}
msgBus := bus.NewMessageBus()
al := NewAgentLoop(cfg, msgBus, &mockProvider{})
activeMsg := bus.InboundMessage{
Context: bus.InboundContext{
Channel: "telegram",
ChatID: "chat1",
ChatType: "direct",
SenderID: "user1",
},
Content: "active turn",
}
activeScope, activeAgentID, ok := al.resolveSteeringTarget(activeMsg)
if !ok {
t.Fatal("expected active message to resolve to a steering scope")
}
otherMsg := bus.InboundMessage{
Context: bus.InboundContext{
Channel: "telegram",
ChatID: "chat2",
ChatType: "direct",
SenderID: "user2",
},
Content: "other session",
}
otherScope, _, ok := al.resolveSteeringTarget(otherMsg)
if !ok {
t.Fatal("expected other message to resolve to a steering scope")
}
if otherScope == activeScope {
t.Fatalf("expected different steering scopes, got same scope %q", activeScope)
}
if err := msgBus.PublishInbound(context.Background(), otherMsg); err != nil {
t.Fatalf("PublishInbound failed: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
done := make(chan struct{})
go func() {
al.drainBusToSteering(ctx, activeScope, activeAgentID)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for drainBusToSteering to stop")
}
if msgs := al.dequeueSteeringMessagesForScope(activeScope); len(msgs) != 0 {
t.Fatalf("expected no steering messages for active scope, got %v", msgs)
}
select {
case <-ctx.Done():
t.Fatalf("timeout waiting for requeued message on inbound bus")
case requeued := <-msgBus.InboundChan():
if requeued.Context.Channel != otherMsg.Context.Channel || requeued.Context.ChatID != otherMsg.Context.ChatID ||
requeued.Content != otherMsg.Content {
t.Fatalf("requeued message mismatch: got %+v want %+v", requeued, otherMsg)
}
}
}
// slowTool simulates a tool that takes some time to execute.
type slowTool struct {
name string

View file

@ -145,7 +145,11 @@ func (al *AgentLoop) clearActiveTurn(ts *turnState) {
func (al *AgentLoop) getActiveTurnState(sessionKey string) *turnState {
if val, ok := al.activeTurnStates.Load(sessionKey); ok {
return val.(*turnState)
if ts, ok := val.(*turnState); ok {
return ts
}
// Unexpected non-*turnState value — treat as "no active turn" to avoid
// panics. This should not happen under normal operation.
}
return nil
}
@ -154,8 +158,11 @@ func (al *AgentLoop) getActiveTurnState(sessionKey string) *turnState {
func (al *AgentLoop) getAnyActiveTurnState() *turnState {
var firstTS *turnState
al.activeTurnStates.Range(func(key, value any) bool {
firstTS = value.(*turnState)
return false // stop after first
if ts, ok := value.(*turnState); ok {
firstTS = ts
return false
}
return true
})
return firstTS
}
@ -165,8 +172,11 @@ func (al *AgentLoop) GetActiveTurn() *ActiveTurnInfo {
// In the new architecture, there can be multiple concurrent turns
var firstTS *turnState
al.activeTurnStates.Range(func(key, value any) bool {
firstTS = value.(*turnState)
return false // stop after first
if ts, ok := value.(*turnState); ok {
firstTS = ts
return false
}
return true
})
if firstTS == nil {
return nil
@ -429,7 +439,9 @@ func (ts *turnState) Finish(isHardAbort bool) {
ts.mu.RUnlock()
for _, childID := range children {
if val, ok := ts.al.activeTurnStates.Load(childID); ok {
val.(*turnState).Finish(true)
if child, ok := val.(*turnState); ok {
child.Finish(true)
}
}
}
}

View file

@ -11,6 +11,7 @@ func BuiltinDefinitions() []Definition {
showCommand(),
listCommand(),
useCommand(),
btwCommand(),
switchCommand(),
checkCommand(),
clearCommand(),

View file

@ -188,3 +188,79 @@ func TestBuiltinUseCommand_PassthroughsToAgentLogic(t *testing.T) {
t.Fatalf("/use command=%q, want=%q", res.Command, "use")
}
}
func TestBuiltinBtwCommand_UsesSideQuestionRuntime(t *testing.T) {
rt := &Runtime{
AskSideQuestion: func(ctx context.Context, question string) (string, error) {
if question != "what is 2+2?" {
t.Fatalf("question=%q, want %q", question, "what is 2+2?")
}
return "4", nil
},
}
defs := BuiltinDefinitions()
ex := NewExecutor(NewRegistry(defs), rt)
var reply string
res := ex.Execute(context.Background(), Request{
Text: "/btw what is 2+2?",
Reply: func(text string) error {
reply = text
return nil
},
})
if res.Outcome != OutcomeHandled {
t.Fatalf("/btw outcome=%v, want=%v", res.Outcome, OutcomeHandled)
}
if reply != "4" {
t.Fatalf("/btw reply=%q, want=%q", reply, "4")
}
}
func TestBuiltinBtwCommand_MissingQuestion(t *testing.T) {
defs := BuiltinDefinitions()
ex := NewExecutor(NewRegistry(defs), &Runtime{
AskSideQuestion: func(context.Context, string) (string, error) {
return "", nil
},
})
var reply string
res := ex.Execute(context.Background(), Request{
Text: "/btw",
Reply: func(text string) error {
reply = text
return nil
},
})
if res.Outcome != OutcomeHandled {
t.Fatalf("/btw outcome=%v, want=%v", res.Outcome, OutcomeHandled)
}
if reply != "Usage: /btw <question>" {
t.Fatalf("/btw reply=%q, want usage message", reply)
}
}
func TestBuiltinBtwCommand_PreservesQuestionWhitespace(t *testing.T) {
const want = "explain:\n fmt.Println(\"hi\")"
rt := &Runtime{
AskSideQuestion: func(ctx context.Context, question string) (string, error) {
if question != want {
t.Fatalf("question=%q, want %q", question, want)
}
return "ok", nil
},
}
defs := BuiltinDefinitions()
ex := NewExecutor(NewRegistry(defs), rt)
res := ex.Execute(context.Background(), Request{
Text: "/btw " + want,
Reply: func(text string) error {
return nil
},
})
if res.Outcome != OutcomeHandled {
t.Fatalf("/btw outcome=%v, want=%v", res.Outcome, OutcomeHandled)
}
}

51
pkg/commands/cmd_btw.go Normal file
View file

@ -0,0 +1,51 @@
package commands
import (
"context"
"strings"
)
func btwCommand() Definition {
return Definition{
Name: "btw",
Description: "Ask a side question without changing session history",
Usage: "/btw <question>",
Handler: func(ctx context.Context, req Request, rt *Runtime) error {
const emptyAnswerMsg = "The model returned an empty response. This may indicate a provider error or token limit."
if rt == nil || rt.AskSideQuestion == nil {
return req.Reply(unavailableMsg)
}
question := sideQuestionText(req.Text)
if question == "" {
return req.Reply("Usage: /btw <question>")
}
answer, err := rt.AskSideQuestion(ctx, question)
if err != nil {
return req.Reply(err.Error())
}
if strings.TrimSpace(answer) == "" {
return req.Reply(emptyAnswerMsg)
}
return req.Reply(answer)
},
}
}
func sideQuestionText(input string) string {
input = strings.TrimSpace(input)
if input == "" {
return ""
}
parts := strings.Fields(input)
if len(parts) < 2 {
return ""
}
if !strings.HasPrefix(input, parts[0]) {
return ""
}
return strings.TrimSpace(input[len(parts[0]):])
}

View file

@ -1,6 +1,10 @@
package commands
import "github.com/sipeed/picoclaw/pkg/config"
import (
"context"
"github.com/sipeed/picoclaw/pkg/config"
)
// Runtime provides runtime dependencies to command handlers. It is constructed
// per-request by the agent loop so that per-request state (like session scope)
@ -8,6 +12,7 @@ import "github.com/sipeed/picoclaw/pkg/config"
type Runtime struct {
Config *config.Config
GetModelInfo func() (name, provider string)
AskSideQuestion func(ctx context.Context, question string) (string, error)
ListAgentIDs func() []string
ListDefinitions func() []Definition
ListSkillNames func() []string

View file

@ -268,7 +268,8 @@ type AgentDefaults struct {
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
Routing *RoutingConfig `json:"routing,omitempty"`
SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all"
SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all"
MaxParallelTurns int `json:"max_parallel_turns,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_PARALLEL_TURNS"` // Max concurrent turns (0 or 1 = sequential)
SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"`
ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"`
SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker

View file

@ -514,6 +514,14 @@ func defaultChannels() ChannelsConfig {
"max_connections": 100,
},
},
"irc": map[string]any{
"settings": map[string]any{
"server": "",
"tls": true,
"nick": "picoclaw",
"channels": []string{},
},
},
}
channels := make(ChannelsConfig, len(defs))

View file

@ -2,8 +2,12 @@ package providers
import (
"context"
"errors"
"io"
"net"
"regexp"
"strings"
"syscall"
)
// Common patterns in Go HTTP error messages
@ -50,6 +54,30 @@ var (
substr("context deadline exceeded"),
}
networkPatterns = []errorPattern{
substr("connection reset"),
substr("reset by peer"),
substr("connection refused"),
substr("connection aborted"),
substr("broken pipe"),
substr("use of closed network connection"),
substr("network is unreachable"),
substr("host is unreachable"),
substr("no such host"),
substr("temporary failure in name resolution"),
substr("server misbehaving"),
substr("read tcp"),
substr("write tcp"),
substr("dial tcp"),
substr("tls:"),
substr("x509:"),
substr("certificate"),
substr("handshake"),
substr("unexpected eof"),
substr("read: eof"),
substr("write: eof"),
}
billingPatterns = []errorPattern{
rxp(`\b402\b`),
substr("payment required"),
@ -134,6 +162,17 @@ func ClassifyError(err error, provider, model string) *FailoverError {
msg := strings.ToLower(err.Error())
// Concrete transport errors should continue the fallback chain even when
// providers do not expose a structured HTTP status.
if reason := classifyByErrorType(err); reason != "" {
return &FailoverError{
Reason: reason,
Provider: provider,
Model: model,
Wrapped: err,
}
}
// Image dimension/size errors: non-retriable, non-fallback.
if IsImageDimensionError(msg) || IsImageSizeError(msg) {
return &FailoverError{
@ -170,6 +209,41 @@ func ClassifyError(err error, provider, model string) *FailoverError {
return nil
}
// classifyByErrorType maps concrete transport-layer error types to a retryable
// fallback reason before message heuristics are applied.
func classifyByErrorType(err error) FailoverReason {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
return FailoverNetwork
}
for _, transportErr := range []error{
syscall.ECONNRESET,
syscall.ECONNABORTED,
syscall.ECONNREFUSED,
syscall.ETIMEDOUT,
syscall.EHOSTUNREACH,
syscall.ENETUNREACH,
syscall.EPIPE,
} {
if errors.Is(err, transportErr) {
if transportErr == syscall.ETIMEDOUT {
return FailoverTimeout
}
return FailoverNetwork
}
}
var netErr net.Error
if errors.As(err, &netErr) {
if netErr.Timeout() {
return FailoverTimeout
}
return FailoverNetwork
}
return ""
}
// classifyByStatus maps HTTP status codes to FailoverReason.
func classifyByStatus(status int) FailoverReason {
switch {
@ -204,6 +278,9 @@ func classifyByMessage(msg string) FailoverReason {
if matchesAny(msg, timeoutPatterns) {
return FailoverTimeout
}
if matchesAny(msg, networkPatterns) {
return FailoverNetwork
}
if matchesAny(msg, authPatterns) {
return FailoverAuth
}

View file

@ -4,9 +4,22 @@ import (
"context"
"errors"
"fmt"
"io"
"net"
"net/url"
"syscall"
"testing"
)
type stubNetError struct {
msg string
timeout bool
}
func (e stubNetError) Error() string { return e.msg }
func (e stubNetError) Timeout() bool { return e.timeout }
func (e stubNetError) Temporary() bool { return false }
func TestClassifyError_Nil(t *testing.T) {
result := ClassifyError(nil, "openai", "gpt-4")
if result != nil {
@ -154,6 +167,129 @@ func TestClassifyError_TimeoutPatterns(t *testing.T) {
}
}
func TestClassifyError_NetworkPatterns(t *testing.T) {
patterns := []string{
`failed to send request: Post "https://example.com": tls: bad record MAC`,
"read tcp 10.20.0.1:61279->172.65.90.20:443: read: connection reset by peer",
"failed to send request: dial tcp 203.0.113.10:443: connect: connection refused",
"tls handshake failure",
"x509: certificate has expired or is not yet valid",
"read tcp 127.0.0.1:443: read: unexpected EOF",
"lookup api.example.com: no such host",
}
for _, msg := range patterns {
err := errors.New(msg)
result := ClassifyError(err, "openai", "gpt-4")
if result == nil {
t.Errorf("pattern %q: expected non-nil", msg)
continue
}
if result.Reason != FailoverNetwork {
t.Errorf("pattern %q: reason = %q, want network", msg, result.Reason)
}
}
}
func TestClassifyError_NetworkTypes(t *testing.T) {
tests := []struct {
name string
err error
}{
{
name: "wrapped EOF",
err: &url.Error{
Op: "Post",
URL: "https://example.com",
Err: io.EOF,
},
},
{
name: "dns error",
err: &net.DNSError{
Err: "no such host",
Name: "api.example.com",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ClassifyError(tt.err, "openai", "gpt-4")
if result == nil {
t.Fatal("expected non-nil")
}
if result.Reason != FailoverNetwork {
t.Fatalf("reason = %q, want network", result.Reason)
}
})
}
}
func TestClassifyError_TimeoutNetworkTypes(t *testing.T) {
tests := []struct {
name string
err error
}{
{
name: "wrapped syscall timeout",
err: fmt.Errorf("dial tcp: %w", syscall.ETIMEDOUT),
},
{
name: "net error timeout",
err: &url.Error{
Op: "Post",
URL: "https://example.com",
Err: stubNetError{msg: "i/o timeout", timeout: true},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ClassifyError(tt.err, "openai", "gpt-4")
if result == nil {
t.Fatal("expected non-nil")
}
if result.Reason != FailoverTimeout {
t.Fatalf("reason = %q, want timeout", result.Reason)
}
})
}
}
func TestClassifyError_TimeoutPatternsWinOverNetworkContext(t *testing.T) {
patterns := []string{
`failed to send request: Post "https://example.com": dial tcp 203.0.113.10:443: i/o timeout`,
`read tcp 10.20.0.1:61279->172.65.90.20:443: i/o timeout`,
}
for _, msg := range patterns {
err := errors.New(msg)
result := ClassifyError(err, "openai", "gpt-4")
if result == nil {
t.Errorf("pattern %q: expected non-nil", msg)
continue
}
if result.Reason != FailoverTimeout {
t.Errorf("pattern %q: reason = %q, want timeout", msg, result.Reason)
}
}
}
func TestClassifyError_NetworkPatternsWinOverAuthExpired(t *testing.T) {
err := errors.New(
`Post "https://example.com": tls: failed to verify certificate: x509: certificate has expired or is not yet valid`,
)
result := ClassifyError(err, "openai", "gpt-4")
if result == nil {
t.Fatal("expected non-nil")
}
if result.Reason != FailoverNetwork {
t.Fatalf("reason = %q, want network", result.Reason)
}
}
func TestClassifyError_AuthPatterns(t *testing.T) {
patterns := []string{
"invalid api key",
@ -286,6 +422,7 @@ func TestFailoverError_IsRetriable(t *testing.T) {
{FailoverAuth, true},
{FailoverRateLimit, true},
{FailoverBilling, true},
{FailoverNetwork, true},
{FailoverTimeout, true},
{FailoverOverloaded, true},
{FailoverFormat, false},

View file

@ -268,6 +268,75 @@ func TestFallback_UnclassifiedError(t *testing.T) {
}
}
func assertFallbackErrorFallsBack(
t *testing.T,
primaryProvider string,
primaryModel string,
initialErr error,
successContent string,
expectedReason FailoverReason,
) {
t.Helper()
ct := NewCooldownTracker()
fc := NewFallbackChain(ct, nil)
candidates := []FallbackCandidate{
makeCandidate(primaryProvider, primaryModel),
makeCandidate("anthropic", "claude"),
}
attempt := 0
run := func(ctx context.Context, provider, model string) (*LLMResponse, error) {
attempt++
if attempt == 1 {
return nil, initialErr
}
return &LLMResponse{Content: successContent, FinishReason: "stop"}, nil
}
result, err := fc.Execute(context.Background(), candidates, run)
if err != nil {
t.Fatalf("expected fallback success, got error: %v", err)
}
if attempt != 2 {
t.Fatalf("attempt = %d, want 2", attempt)
}
if result.Provider != "anthropic" || result.Model != "claude" {
t.Fatalf("result = %s/%s, want anthropic/claude", result.Provider, result.Model)
}
if len(result.Attempts) != 1 {
t.Fatalf("attempts = %d, want 1 failed attempt recorded", len(result.Attempts))
}
if result.Attempts[0].Reason != expectedReason {
t.Fatalf("attempt reason = %q, want %s", result.Attempts[0].Reason, expectedReason)
}
}
func TestFallback_NetworkErrorFallsBack(t *testing.T) {
assertFallbackErrorFallsBack(
t,
"minimax",
"minimax-m2.7",
errors.New(
`failed to send request: Post "https://opencode.ai/zen/go/v1/chat/completions": tls: bad record MAC`,
),
"fallback ok",
FailoverNetwork,
)
}
func TestFallback_TimeoutErrorFallsBack(t *testing.T) {
assertFallbackErrorFallsBack(
t,
"openai",
"gpt-4",
errors.New("failed to send request: Post \"https://example.com\": i/o timeout"),
"timeout fallback ok",
FailoverTimeout,
)
}
func TestFallback_SuccessResetsCooldown(t *testing.T) {
ct := NewCooldownTracker()
fc := NewFallbackChain(ct, nil)

View file

@ -75,6 +75,7 @@ const (
FailoverAuth FailoverReason = "auth"
FailoverRateLimit FailoverReason = "rate_limit"
FailoverBilling FailoverReason = "billing"
FailoverNetwork FailoverReason = "network"
FailoverTimeout FailoverReason = "timeout"
FailoverFormat FailoverReason = "format"
FailoverContextOverflow FailoverReason = "context_overflow"

View file

@ -6,6 +6,8 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
@ -18,7 +20,7 @@ type JobExecutor interface {
ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error)
// PublishResponseIfNeeded sends response to the outbound bus only when the
// agent did not already deliver content through the message tool in this round.
PublishResponseIfNeeded(ctx context.Context, channel, chatID, response string)
PublishResponseIfNeeded(ctx context.Context, channel, chatID, sessionKey, response string)
}
// CronTool provides scheduling capabilities for the agent
@ -340,7 +342,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
return "ok"
}
sessionKey := fmt.Sprintf("cron-%s", job.ID)
sessionKey := fmt.Sprintf("agent:cron-%s-%s", job.ID, uuid.New().String())
// Call agent with the job message
response, err := t.executor.ProcessDirectWithChannel(
@ -355,7 +357,7 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
}
if response != "" {
t.executor.PublishResponseIfNeeded(ctx, channel, chatID, response)
t.executor.PublishResponseIfNeeded(ctx, channel, chatID, "", response)
}
return "ok"
}

View file

@ -39,7 +39,7 @@ func (s *stubJobExecutor) ProcessDirectWithChannel(
func (s *stubJobExecutor) PublishResponseIfNeeded(
_ context.Context,
channel, chatID, response string,
channel, chatID, sessionKey, response string,
) {
if s.alreadySent {
return
@ -271,8 +271,8 @@ func TestCronTool_ExecuteJobPublishesAgentResponse(t *testing.T) {
t.Fatalf("ExecuteJob() = %q, want ok", got)
}
if executor.lastKey != "cron-job-1" {
t.Fatalf("sessionKey = %q, want cron-job-1", executor.lastKey)
if !strings.HasPrefix(executor.lastKey, "agent:cron-job-1-") {
t.Fatalf("sessionKey = %q, want agent:cron-job-1-{uuid}", executor.lastKey)
}
if executor.lastChan != "telegram" || executor.lastChatID != "chat-1" {
t.Fatalf("executor target = %s/%s, want telegram/chat-1", executor.lastChan, executor.lastChatID)

View file

@ -17,11 +17,15 @@ type sentTarget struct {
type MessageTool struct {
sendCallback SendCallbackWithContext
mu sync.Mutex
sentTargets []sentTarget // Tracks all targets sent to in the current round
// sentTargets tracks targets sent to in the current round, keyed by session key
// to support parallel turns for different sessions.
sentTargets map[string][]sentTarget
}
func NewMessageTool() *MessageTool {
return &MessageTool{}
return &MessageTool{
sentTargets: make(map[string][]sentTarget),
}
}
func (t *MessageTool) Name() string {
@ -57,28 +61,31 @@ func (t *MessageTool) Parameters() map[string]any {
}
}
// ResetSentInRound resets the per-round send tracker.
// ResetSentInRound resets the per-round send tracker for the given session key.
// Called by the agent loop at the start of each inbound message processing round.
func (t *MessageTool) ResetSentInRound() {
func (t *MessageTool) ResetSentInRound(sessionKey string) {
t.mu.Lock()
t.sentTargets = t.sentTargets[:0]
t.mu.Unlock()
defer t.mu.Unlock()
// Delete the key entirely to prevent unbounded map growth over time
// with many unique sessions. Truncating the slice keeps the key alive.
delete(t.sentTargets, sessionKey)
}
// HasSentInRound returns true if the message tool sent a message during the current round.
func (t *MessageTool) HasSentInRound() bool {
func (t *MessageTool) HasSentInRound(sessionKey string) bool {
t.mu.Lock()
defer t.mu.Unlock()
return len(t.sentTargets) > 0
return len(t.sentTargets[sessionKey]) > 0
}
// HasSentTo returns true if the message tool sent to the specific channel+chatID
// during the current round. Used by PublishResponseIfNeeded to avoid suppressing
// the final response when the message tool only sent to a different conversation.
func (t *MessageTool) HasSentTo(channel, chatID string) bool {
func (t *MessageTool) HasSentTo(sessionKey, channel, chatID string) bool {
t.mu.Lock()
defer t.mu.Unlock()
for _, st := range t.sentTargets {
for _, st := range t.sentTargets[sessionKey] {
if st.Channel == channel && st.ChatID == chatID {
return true
}
@ -123,8 +130,9 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
}
}
sessionKey := ToolSessionKey(ctx)
t.mu.Lock()
t.sentTargets = append(t.sentTargets, sentTarget{Channel: channel, ChatID: chatID})
t.sentTargets[sessionKey] = append(t.sentTargets[sessionKey], sentTarget{Channel: channel, ChatID: chatID})
t.mu.Unlock()
// Silent: user already received the message directly

View file

@ -812,6 +812,8 @@ func (p *PerplexitySearchProvider) Search(
type SearXNGSearchProvider struct {
baseURL string
proxy string
client *http.Client
}
func (p *SearXNGSearchProvider) Search(
@ -836,7 +838,10 @@ func (p *SearXNGSearchProvider) Search(
return "", fmt.Errorf("failed to create request: %w", err)
}
client := &http.Client{Timeout: 10 * time.Second}
client := p.client
if client == nil {
client = &http.Client{Timeout: searchTimeout}
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
@ -1166,12 +1171,18 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in
if !opts.SearXNGEnabled {
return nil, 0, nil
}
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
if err != nil {
return nil, 0, fmt.Errorf("failed to create HTTP client for SearXNG: %w", err)
}
maxResults := 10
if opts.SearXNGMaxResults > 0 {
maxResults = min(opts.SearXNGMaxResults, 10)
}
return &SearXNGSearchProvider{
baseURL: opts.SearXNGBaseURL,
proxy: opts.Proxy,
client: client,
}, maxResults, nil
case "tavily":
if !opts.TavilyEnabled {
@ -1458,6 +1469,8 @@ type privateHostWhitelist struct {
cidrs []*net.IPNet
}
type webFetchAllowedFirstHopHostKey struct{}
func NewWebFetchTool(maxChars int, format string, fetchLimitBytes int64) (*WebFetchTool, error) {
// createHTTPClient cannot fail with an empty proxy string.
return NewWebFetchToolWithConfig(maxChars, "", format, fetchLimitBytes, nil)
@ -1509,6 +1522,7 @@ func NewWebFetchToolWithConfig(
if isObviousPrivateHost(req.URL.Hostname(), whitelist) {
return fmt.Errorf("redirect target is private or local network host")
}
allowConfiguredProxyFirstHop(req, client.Transport)
return nil
}
if fetchLimitBytes <= 0 {
@ -1588,6 +1602,7 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe
if reqErr != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", reqErr)
}
allowConfiguredProxyFirstHop(req, t.client.Transport)
req.Header.Set("User-Agent", ua)
resp, doErr := t.client.Do(req)
if doErr != nil {
@ -1790,6 +1805,9 @@ func newSafeDialContext(
if host == "" {
return nil, fmt.Errorf("empty target host")
}
if isAllowedFirstHopHost(ctx, host) {
return dialer.DialContext(ctx, network, address)
}
if ip := net.ParseIP(host); ip != nil {
if shouldBlockPrivateIP(ip, whitelist) {
@ -1838,6 +1856,46 @@ func newSafeDialContext(
}
}
func allowConfiguredProxyFirstHop(req *http.Request, rt http.RoundTripper) {
if req == nil {
return
}
transport, ok := rt.(*http.Transport)
if !ok || transport.Proxy == nil {
return
}
proxyURL, err := transport.Proxy(req)
if err != nil || proxyURL == nil {
return
}
host := normalizeAllowedFirstHopHost(proxyURL.Hostname())
if host == "" {
return
}
*req = *req.WithContext(context.WithValue(
req.Context(),
webFetchAllowedFirstHopHostKey{},
host,
))
}
func isAllowedFirstHopHost(ctx context.Context, host string) bool {
allowed, _ := ctx.Value(webFetchAllowedFirstHopHostKey{}).(string)
if allowed == "" {
return false
}
return allowed == normalizeAllowedFirstHopHost(host)
}
func normalizeAllowedFirstHopHost(host string) string {
host = strings.ToLower(strings.TrimSpace(host))
return strings.TrimSuffix(host, ".")
}
func newPrivateHostWhitelist(entries []string) (*privateHostWhitelist, error) {
if len(entries) == 0 {
return nil, nil

View file

@ -767,6 +767,33 @@ func TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) {
}
}
func TestWebTool_WebFetch_AllowsLoopbackProxy(t *testing.T) {
proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.String() != "http://example.com/proxied" {
t.Fatalf("proxy received URL %q, want %q", r.URL.String(), "http://example.com/proxied")
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusOK)
w.Write([]byte("proxied content"))
}))
defer proxy.Close()
tool, err := NewWebFetchToolWithProxy(50000, proxy.URL, format, testFetchLimit, nil)
if err != nil {
t.Fatalf("Failed to create web fetch tool: %v", err)
}
result := tool.Execute(context.Background(), map[string]any{
"url": "http://example.com/proxied",
})
if result.IsError {
t.Fatalf("expected success through loopback proxy, got %q", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "proxied content") {
t.Fatalf("expected proxied content, got %q", result.ForLLM)
}
}
// TestWebFetch_BlocksIPv4MappedIPv6Loopback verifies ::ffff:127.0.0.1 is blocked
func TestWebFetch_BlocksIPv4MappedIPv6Loopback(t *testing.T) {
tool, err := NewWebFetchTool(50000, format, testFetchLimit)
@ -1092,6 +1119,40 @@ func TestNewWebSearchTool_PropagatesProxy(t *testing.T) {
t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
}
})
t.Run("searxng", func(t *testing.T) {
tool, err := NewWebSearchTool(WebSearchToolOptions{
SearXNGEnabled: true,
SearXNGBaseURL: "https://searx.example.com",
SearXNGMaxResults: 3,
Proxy: "http://127.0.0.1:7890",
})
if err != nil {
t.Fatalf("NewWebSearchTool() error: %v", err)
}
p, ok := tool.provider.(*SearXNGSearchProvider)
if !ok {
t.Fatalf("provider type = %T, want *SearXNGSearchProvider", tool.provider)
}
if p.proxy != "http://127.0.0.1:7890" {
t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890")
}
tr, ok := p.client.Transport.(*http.Transport)
if !ok {
t.Fatalf("client.Transport type = %T, want *http.Transport", p.client.Transport)
}
req, err := http.NewRequest(http.MethodGet, "https://searx.example.com/search", nil)
if err != nil {
t.Fatalf("http.NewRequest() error: %v", err)
}
proxyURL, err := tr.Proxy(req)
if err != nil {
t.Fatalf("transport.Proxy(req) error: %v", err)
}
if proxyURL == nil || proxyURL.String() != "http://127.0.0.1:7890" {
t.Fatalf("proxy URL = %v, want %q", proxyURL, "http://127.0.0.1:7890")
}
})
}
// TestWebTool_TavilySearch_Success verifies successful Tavily search

View file

@ -117,8 +117,11 @@ func buildChannelConfigResponse(cfg *config.Config, item channelCatalogItem) cha
bc := cfg.Channels.Get(item.ConfigKey)
if bc == nil {
resp.Config = map[string]any{}
return resp
bc = defaultChannelConfig(item.ConfigKey)
if bc == nil {
resp.Config = map[string]any{}
return resp
}
}
// Detect configured secrets by checking the raw Settings JSON
@ -126,21 +129,47 @@ func buildChannelConfigResponse(cfg *config.Config, item channelCatalogItem) cha
resp.ConfiguredSecrets = secrets
// Parse settings into a generic map for JSON response
var settings map[string]any
if err := json.Unmarshal(bc.Settings, &settings); err != nil {
resp.Config = map[string]any{}
return resp
settings := map[string]any{}
if len(bc.Settings) > 0 {
if err := json.Unmarshal(bc.Settings, &settings); err != nil {
resp.Config = map[string]any{}
return resp
}
}
// Remove secure fields from response
for _, key := range secrets {
delete(settings, key)
}
addChannelCommonConfig(settings, bc)
resp.Config = settings
return resp
}
func defaultChannelConfig(configKey string) *config.Channel {
return config.DefaultConfig().Channels.Get(configKey)
}
func addChannelCommonConfig(settings map[string]any, bc *config.Channel) {
settings["enabled"] = bc.Enabled
if len(bc.AllowFrom) > 0 {
settings["allow_from"] = []string(bc.AllowFrom)
}
if bc.ReasoningChannelID != "" {
settings["reasoning_channel_id"] = bc.ReasoningChannelID
}
if bc.GroupTrigger.MentionOnly || len(bc.GroupTrigger.Prefixes) > 0 {
settings["group_trigger"] = bc.GroupTrigger
}
if bc.Typing.Enabled {
settings["typing"] = bc.Typing
}
if bc.Placeholder.Enabled || len(bc.Placeholder.Text) > 0 {
settings["placeholder"] = bc.Placeholder
}
}
func detectConfiguredSecrets(settings config.RawNode, channelName string) []string {
var m map[string]any
if err := json.Unmarshal(settings, &m); err != nil {

View file

@ -27,6 +27,7 @@ func TestHandleGetChannelConfig_ReturnsSecretPresenceWithoutLeakingSecrets(t *te
bcfg := decoded.(*config.FeishuSettings)
bcfg.AppID = "cli_test_app"
bcfg.AppSecret = *config.NewSecureString("feishu-secret-from-security")
bc.AllowFrom = config.FlexibleStringSlice{"ou_test_user"}
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
@ -67,6 +68,13 @@ func TestHandleGetChannelConfig_ReturnsSecretPresenceWithoutLeakingSecrets(t *te
if got := resp.Config["app_id"]; got != "cli_test_app" {
t.Fatalf("config.app_id = %#v, want %q", got, "cli_test_app")
}
if got := resp.Config["enabled"]; got != true {
t.Fatalf("config.enabled = %#v, want true", got)
}
allowFrom, ok := resp.Config["allow_from"].([]any)
if !ok || len(allowFrom) != 1 || allowFrom[0] != "ou_test_user" {
t.Fatalf("config.allow_from = %#v, want [\"ou_test_user\"]", resp.Config["allow_from"])
}
if _, exists := resp.Config["app_secret"]; exists {
t.Fatalf("config should omit app_secret, got %#v", resp.Config["app_secret"])
}
@ -91,3 +99,97 @@ func TestHandleGetChannelConfig_ReturnsNotFoundForUnknownChannel(t *testing.T) {
t.Fatalf("GET /api/channels/not-a-channel/config status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
func TestHandleGetChannelConfig_ReturnsCommonFieldsWhenSettingsEmpty(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
bc := cfg.Channels[config.ChannelFeishu]
bc.Enabled = true
bc.AllowFrom = config.FlexibleStringSlice{"ou_common_user"}
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodGet, "/api/channels/feishu/config", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(
"GET /api/channels/feishu/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
var resp struct {
Config map[string]any `json:"config"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if got := resp.Config["enabled"]; got != true {
t.Fatalf("config.enabled = %#v, want true", got)
}
allowFrom, ok := resp.Config["allow_from"].([]any)
if !ok || len(allowFrom) != 1 || allowFrom[0] != "ou_common_user" {
t.Fatalf("config.allow_from = %#v, want [\"ou_common_user\"]", resp.Config["allow_from"])
}
}
func TestHandleGetChannelConfig_ReturnsDefaultShapeForMissingChannel(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
delete(cfg.Channels, config.ChannelIRC)
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodGet, "/api/channels/irc/config", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf(
"GET /api/channels/irc/config status = %d, want %d, body=%s",
rec.Code,
http.StatusOK,
rec.Body.String(),
)
}
var resp struct {
Config map[string]any `json:"config"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("json.Unmarshal() error = %v", err)
}
if got := resp.Config["server"]; got != "" {
t.Fatalf("config.server = %#v, want empty string", got)
}
if got := resp.Config["nick"]; got != "picoclaw" {
t.Fatalf("config.nick = %#v, want %q", got, "picoclaw")
}
if got := resp.Config["enabled"]; got != false {
t.Fatalf("config.enabled = %#v, want false", got)
}
}

View file

@ -174,6 +174,130 @@ func TestHandlePatchConfig_AllowsInvalidExecRegexPatternsWhenExecDisabled(t *tes
}
}
func TestHandlePatchConfig_SavesChannelListSettingsPatch(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
"channel_list": {
"feishu": {
"enabled": true,
"allow_from": ["ou_patch_user"],
"settings": {
"app_id": "cli_patch_app",
"app_secret": "patch-secret",
"is_lark": true
}
}
}
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
bc := cfg.Channels[config.ChannelFeishu]
if !bc.Enabled {
t.Fatal("feishu should be enabled after PATCH")
}
if len(bc.AllowFrom) != 1 || bc.AllowFrom[0] != "ou_patch_user" {
t.Fatalf("feishu allow_from = %#v, want [\"ou_patch_user\"]", bc.AllowFrom)
}
decoded, err := bc.GetDecoded()
if err != nil {
t.Fatalf("GetDecoded() error = %v", err)
}
feishuCfg := decoded.(*config.FeishuSettings)
if got := feishuCfg.AppID; got != "cli_patch_app" {
t.Fatalf("feishu app_id = %q, want %q", got, "cli_patch_app")
}
if got := feishuCfg.AppSecret.String(); got != "patch-secret" {
t.Fatalf("feishu app_secret = %q, want %q", got, "patch-secret")
}
if !feishuCfg.IsLark {
t.Fatal("feishu is_lark should be true after PATCH")
}
}
func TestHandlePatchConfig_CreatesMissingChannelWithTypeAndSecret(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
delete(cfg.Channels, config.ChannelIRC)
if err = config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
"channel_list": {
"irc": {
"enabled": true,
"type": "irc",
"settings": {
"server": "irc.example.com",
"password": "irc-patch-password"
}
}
}
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
cfg, err = config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
bc := cfg.Channels[config.ChannelIRC]
if bc == nil {
t.Fatal("irc channel should exist after PATCH")
}
if got := bc.Type; got != config.ChannelIRC {
t.Fatalf("irc type = %q, want %q", got, config.ChannelIRC)
}
decoded, err := bc.GetDecoded()
if err != nil {
t.Fatalf("GetDecoded() error = %v", err)
}
ircCfg := decoded.(*config.IRCSettings)
if got := ircCfg.Server; got != "irc.example.com" {
t.Fatalf("irc server = %q, want %q", got, "irc.example.com")
}
if got := ircCfg.Password.String(); got != "irc-patch-password" {
t.Fatalf("irc password = %q, want %q", got, "irc-patch-password")
}
configData, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("ReadFile(configPath) error = %v", err)
}
if bytes.Contains(configData, []byte("irc-patch-password")) {
t.Fatalf("config file leaked irc password: %s", string(configData))
}
}
// setupPicoEnabledEnv creates a test environment with Pico channel enabled and
// its token stored only in .security.yml (not in the JSON payload).
func setupPicoEnabledEnv(t *testing.T) (string, func()) {

View file

@ -0,0 +1,245 @@
import { IconSearch } from "@tabler/icons-react"
import { useTranslation } from "react-i18next"
import type { ToolSupportItem } from "@/api/tools"
import { Card, CardContent } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { ToolStatusBadge } from "./tool-status-badge"
import type { GroupedTools, ToolStatusFilter } from "./types"
interface ToolLibraryTabProps {
allTools: ToolSupportItem[]
groupedTools: GroupedTools
totalFilteredCount: number
searchQuery: string
statusFilter: ToolStatusFilter
isLoading: boolean
hasError: boolean
pendingToolName: string | null
onSearchQueryChange: (value: string) => void
onStatusFilterChange: (value: ToolStatusFilter) => void
onToggleTool: (name: string, enabled: boolean) => void
}
export function ToolLibraryTab({
allTools,
groupedTools,
totalFilteredCount,
searchQuery,
statusFilter,
isLoading,
hasError,
pendingToolName,
onSearchQueryChange,
onStatusFilterChange,
onToggleTool,
}: ToolLibraryTabProps) {
const { t } = useTranslation()
return (
<div className="animate-in fade-in slide-in-from-bottom-2 space-y-12 duration-500">
<div className="flex flex-col gap-6 pt-2 sm:flex-row sm:items-end sm:justify-between">
<div className="hidden max-w-sm space-y-2 md:block">
<h1 className="text-foreground/90 text-2xl font-semibold tracking-tight">
{t("pages.agent.tools.library_title", "Tool Library")}
</h1>
<p className="text-muted-foreground/80 text-[14px] leading-relaxed">
{t(
"pages.agent.tools.library_description",
"Browse and manage the toolset available to your AI agents.",
)}
</p>
</div>
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center md:w-auto">
<div className="group relative flex-1 md:w-80">
<IconSearch className="text-muted-foreground/60 group-focus-within:text-foreground/80 absolute top-1/2 left-3.5 size-4 -translate-y-1/2 transition-colors" />
<Input
type="text"
placeholder={t(
"pages.agent.tools.search_placeholder",
"Search tools...",
)}
className="bg-muted/40 hover:bg-muted/60 focus-visible:bg-background focus-visible:border-border/80 focus-visible:ring-foreground/5 h-11 w-full rounded-xl border-transparent pl-10 shadow-none transition-all duration-300"
value={searchQuery}
onChange={(event) => onSearchQueryChange(event.target.value)}
/>
</div>
<Select
value={statusFilter}
onValueChange={(value) =>
onStatusFilterChange(value as ToolStatusFilter)
}
>
<SelectTrigger className="bg-muted/40 hover:bg-muted/60 focus:ring-foreground/5 focus:border-border/80 h-11 w-full rounded-xl border-transparent shadow-none transition-all duration-300 sm:w-36">
<SelectValue
placeholder={t("pages.agent.tools.filter.all", "All Status")}
/>
</SelectTrigger>
<SelectContent className="border-border/40 rounded-xl shadow-lg">
<SelectItem value="all">
{t("pages.agent.tools.filter.all", "All Status")}
</SelectItem>
<SelectItem value="enabled">
{t("pages.agent.tools.filter.enabled", "Enabled")}
</SelectItem>
<SelectItem value="disabled">
{t("pages.agent.tools.filter.disabled", "Disabled")}
</SelectItem>
<SelectItem value="blocked">
{t("pages.agent.tools.filter.blocked", "Blocked")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{hasError ? (
<div className="py-20 text-center">
<p className="text-destructive font-medium">
{t("pages.agent.load_error", "Failed to load tools")}
</p>
</div>
) : isLoading ? (
<LibraryLoadingState />
) : totalFilteredCount === 0 ? (
<LibraryEmptyState allToolsCount={allTools.length} />
) : (
<div className="space-y-12">
{groupedTools.map(([category, items]) => (
<section key={category} className="space-y-6">
<div className="flex items-center">
<h3 className="text-foreground/90 text-lg font-semibold tracking-tight capitalize">
{t(`pages.agent.tools.categories.${category}`, category)}
</h3>
</div>
<div className="grid gap-5 lg:grid-cols-2">
{items.map((tool) => (
<ToolCard
key={tool.name}
tool={tool}
isPending={pendingToolName === tool.name}
onToggleTool={onToggleTool}
/>
))}
</div>
</section>
))}
</div>
)}
</div>
)
}
function ToolCard({
tool,
isPending,
onToggleTool,
}: {
tool: ToolSupportItem
isPending: boolean
onToggleTool: (name: string, enabled: boolean) => void
}) {
const { t } = useTranslation()
const reasonText = tool.reason_code
? t(`pages.agent.tools.reasons.${tool.reason_code}`)
: ""
const isEnabled = tool.status === "enabled"
const isDisabled = tool.status === "disabled"
const isBlocked = tool.status === "blocked"
return (
<Card
className={cn(
"group bg-card border-border/40 flex flex-col shadow-none transition-all duration-300 sm:rounded-2xl",
isBlocked
? "border-amber-500/30 bg-amber-50/20 dark:border-amber-900/40 dark:bg-amber-950/20"
: "hover:border-border/80 hover:-translate-y-[2px] hover:shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)] dark:hover:shadow-[0_4px_20px_-4px_rgba(255,255,255,0.02)]",
isDisabled && "opacity-[0.80] hover:opacity-100",
)}
>
<CardContent className="flex h-full flex-col p-6">
<div className="mb-3 flex items-start justify-between gap-4">
<div className="flex min-w-0 flex-1 items-center gap-3">
<h4 className="text-foreground/90 min-w-0 break-all font-mono text-sm font-semibold tracking-tight">
{tool.name}
</h4>
<ToolStatusBadge status={tool.status} />
</div>
<Switch
checked={isEnabled}
disabled={isPending}
onCheckedChange={(checked) => onToggleTool(tool.name, checked)}
className={cn(
"shrink-0",
isEnabled && "shadow-xs ring-1 ring-emerald-500/20",
)}
/>
</div>
<p className="text-muted-foreground/80 flex-1 text-[14px] leading-relaxed">
{tool.description}
</p>
{reasonText && (
<div className="border-border/40 mt-4 border-t pt-4">
<div className="inline-flex rounded-lg border border-amber-200/50 bg-amber-50/80 px-3 py-2 text-[13px] font-medium text-amber-600 dark:border-amber-500/20 dark:bg-amber-500/10 dark:text-amber-400">
{reasonText}
</div>
</div>
)}
</CardContent>
</Card>
)
}
function LibraryLoadingState() {
return (
<div className="space-y-12">
{[1, 2].map((groupIndex) => (
<div key={groupIndex} className="space-y-6">
<Skeleton className="h-6 w-32 rounded-md" />
<div className="grid gap-5 lg:grid-cols-2">
{[1, 2].map((itemIndex) => (
<Skeleton key={itemIndex} className="h-36 rounded-2xl" />
))}
</div>
</div>
))}
</div>
)
}
function LibraryEmptyState({ allToolsCount }: { allToolsCount: number }) {
const { t } = useTranslation()
return (
<div className="flex flex-col items-center justify-center py-32 text-center">
<div className="bg-muted/30 ring-border/10 mb-6 rounded-full p-6 shadow-xs ring-1">
<IconSearch className="text-muted-foreground/60 size-10" />
</div>
<h3 className="text-foreground/80 mb-2 text-xl font-semibold tracking-tight">
{allToolsCount === 0
? t("pages.agent.tools.empty", "No tools found")
: t("pages.agent.tools.no_results", "No matching tools")}
</h3>
{allToolsCount !== 0 && (
<p className="text-muted-foreground text-sm">
Try adjusting your search criteria or status filters.
</p>
)}
</div>
)
}

View file

@ -0,0 +1,28 @@
import { useTranslation } from "react-i18next"
import type { ToolSupportItem } from "@/api/tools"
import { cn } from "@/lib/utils"
interface ToolStatusBadgeProps {
status: ToolSupportItem["status"]
}
export function ToolStatusBadge({ status }: ToolStatusBadgeProps) {
const { t } = useTranslation()
return (
<span
className={cn(
"shrink-0 rounded-full px-2.5 py-0.5 text-[11px] font-medium tracking-wide sm:text-[11px]",
status === "enabled" &&
"bg-emerald-500/10 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400",
status === "blocked" &&
"bg-amber-500/10 text-amber-600 dark:bg-amber-500/20 dark:text-amber-400",
status === "disabled" &&
"bg-muted text-muted-foreground/80 dark:bg-muted-foreground/20 dark:text-muted-foreground",
)}
>
{t(`pages.agent.tools.status.${status}`, status)}
</span>
)
}

View file

@ -1,574 +1,76 @@
import { IconSearch } from "@tabler/icons-react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import {
getTools,
getWebSearchConfig,
setToolEnabled,
type ToolSupportItem,
type WebSearchConfigResponse,
updateWebSearchConfig,
} from "@/api/tools"
import { PageHeader } from "@/components/page-header"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { KeyInput } from "@/components/shared-form"
import { Button } from "@/components/ui/button"
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { refreshGatewayState } from "@/store/gateway"
import { ToolLibraryTab } from "./tool-library-tab"
import { ToolsTabs } from "./tools-tabs"
import { useToolsPage } from "./use-tools-page"
import { WebSearchTab } from "./web-search-tab"
export function ToolsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data, isLoading, error } = useQuery({
queryKey: ["tools"],
queryFn: getTools,
})
const {
data: webSearchData,
isLoading: isWebSearchLoading,
error: webSearchError,
} = useQuery({
queryKey: ["tools", "web-search-config"],
queryFn: getWebSearchConfig,
})
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState("all")
const [webSearchDraft, setWebSearchDraft] =
useState<WebSearchConfigResponse | null>(null)
useEffect(() => {
if (webSearchData) {
setWebSearchDraft(webSearchData)
}
}, [webSearchData])
const toggleMutation = useMutation({
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
setToolEnabled(name, enabled),
onSuccess: (_, variables) => {
toast.success(
variables.enabled
? t("pages.agent.tools.enable_success")
: t("pages.agent.tools.disable_success"),
)
void queryClient.invalidateQueries({ queryKey: ["tools"] })
void refreshGatewayState({ force: true })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.tools.toggle_error"),
)
},
})
const webSearchMutation = useMutation({
mutationFn: updateWebSearchConfig,
onSuccess: (updated) => {
setWebSearchDraft(updated)
toast.success(t("pages.agent.tools.web_search.save_success"))
void queryClient.invalidateQueries({ queryKey: ["tools", "web-search-config"] })
void queryClient.invalidateQueries({ queryKey: ["tools"] })
void refreshGatewayState({ force: true })
},
onError: (err) => {
toast.error(
err instanceof Error
? err.message
: t("pages.agent.tools.web_search.save_error"),
)
},
})
// Filter and group tools
const { groupedTools, totalFilteredCount } = useMemo(() => {
if (!data) return { groupedTools: [], totalFilteredCount: 0 }
let count = 0
const buckets = new Map<string, ToolSupportItem[]>()
for (const item of data.tools) {
// Apply status filter
if (statusFilter !== "all" && item.status !== statusFilter) continue
// Apply search query
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase()
const matchesName = item.name.toLowerCase().includes(query)
const matchesDesc = (item.description || "")
.toLowerCase()
.includes(query)
if (!matchesName && !matchesDesc) continue
}
count++
const list = buckets.get(item.category) ?? []
list.push(item)
buckets.set(item.category, list)
}
return {
groupedTools: Array.from(buckets.entries()),
totalFilteredCount: count,
}
}, [data, searchQuery, statusFilter])
const providerLabelMap = useMemo(() => {
const entries = webSearchDraft?.providers ?? []
return new Map(entries.map((item) => [item.id, item.label]))
}, [webSearchDraft])
const currentProviderLabel = webSearchDraft?.current_service
? (providerLabelMap.get(webSearchDraft.current_service) ??
webSearchDraft.current_service)
: t("pages.agent.tools.web_search.none")
const updateDraft = (
updater: (current: WebSearchConfigResponse) => WebSearchConfigResponse,
) => {
setWebSearchDraft((current) => (current ? updater(current) : current))
}
activeTab,
currentProviderLabel,
expandedProvider,
groupedTools,
pendingToolName,
providerLabelMap,
searchQuery,
statusFilter,
tools,
totalFilteredCount,
webSearchDraft,
hasToolsError,
hasWebSearchError,
isToolsLoading,
isWebSearchLoading,
isWebSearchSaving,
setActiveTab,
setSearchQuery,
setStatusFilter,
saveWebSearchConfig,
toggleExpandedProvider,
toggleTool,
updateWebSearchDraft,
} = useToolsPage()
return (
<div className="bg-background flex h-full flex-col">
<PageHeader title={t("navigation.tools")} />
<PageHeader title={t("navigation.tools", "Tools")} />
<ToolsTabs activeTab={activeTab} onChange={setActiveTab} />
<div className="flex-1 overflow-auto px-6 py-6">
<div className="mx-auto w-full max-w-6xl space-y-8">
{webSearchError ? (
<Card className="border-destructive/50 bg-destructive/10 cursor-default">
<CardHeader>
<CardTitle>{t("pages.agent.tools.web_search.title")}</CardTitle>
<CardDescription>{t("pages.agent.tools.web_search.load_error")}</CardDescription>
</CardHeader>
</Card>
) : isWebSearchLoading || !webSearchDraft ? (
<Card className="border-border/60 shadow-none">
<CardHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="h-4 w-80" />
</CardHeader>
<CardContent className="grid gap-4 lg:grid-cols-2">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-24 w-full lg:col-span-2" />
</CardContent>
</Card>
<div className="flex-1 overflow-auto px-6 py-6 pb-20">
<div className="mx-auto w-full max-w-6xl">
{activeTab === "library" ? (
<ToolLibraryTab
allTools={tools}
groupedTools={groupedTools}
totalFilteredCount={totalFilteredCount}
searchQuery={searchQuery}
statusFilter={statusFilter}
isLoading={isToolsLoading}
hasError={hasToolsError}
pendingToolName={pendingToolName}
onSearchQueryChange={setSearchQuery}
onStatusFilterChange={setStatusFilter}
onToggleTool={toggleTool}
/>
) : (
<Card className="border-border/60 shadow-none">
<CardHeader>
<CardTitle>{t("pages.agent.tools.web_search.title")}</CardTitle>
<CardDescription>
{t("pages.agent.tools.web_search.description")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 lg:grid-cols-3">
<div className="space-y-2">
<div className="text-sm font-medium">
{t("pages.agent.tools.web_search.current_service")}
</div>
<div className="text-muted-foreground rounded-md border px-3 py-2 text-sm">
{currentProviderLabel}
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">
{t("pages.agent.tools.web_search.provider")}
</div>
<Select
value={webSearchDraft.provider}
onValueChange={(value) =>
updateDraft((current) => ({ ...current, provider: value }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{webSearchDraft.providers.map((provider) => (
<SelectItem key={provider.id} value={provider.id}>
{provider.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">
{t("pages.agent.tools.web_search.proxy")}
</div>
<Input
value={webSearchDraft.proxy ?? ""}
onChange={(e) =>
updateDraft((current) => ({
...current,
proxy: e.target.value,
}))
}
placeholder="http://127.0.0.1:7890"
/>
</div>
</div>
<div className="flex items-center justify-between rounded-md border px-4 py-3">
<div>
<div className="text-sm font-medium">
{t("pages.agent.tools.web_search.prefer_native")}
</div>
<div className="text-muted-foreground text-xs">
{t("pages.agent.tools.web_search.prefer_native_hint")}
</div>
</div>
<Switch
checked={webSearchDraft.prefer_native}
onCheckedChange={(checked) =>
updateDraft((current) => ({
...current,
prefer_native: checked,
}))
}
/>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{Object.entries(webSearchDraft.settings).map(([providerId, settings]) => {
const providerLabel = providerLabelMap.get(providerId) ?? providerId
const apiKeyPlaceholder = maskedSecretPlaceholder(
settings.api_key_set ? `${providerId}-configured` : "",
t("pages.agent.tools.web_search.api_key_placeholder"),
)
return (
<Card key={providerId} className="border-border/60 shadow-none">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<div>
<CardTitle className="text-base">{providerLabel}</CardTitle>
<CardDescription className="mt-1 text-xs">
{t("pages.agent.tools.web_search.provider_hint")}
</CardDescription>
</div>
<Switch
checked={settings.enabled}
onCheckedChange={(checked) =>
updateDraft((current) => ({
...current,
settings: {
...current.settings,
[providerId]: {
...current.settings[providerId],
enabled: checked,
},
},
}))
}
/>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-2">
<div className="text-sm font-medium">
{t("pages.agent.tools.web_search.max_results")}
</div>
<Input
type="number"
min={1}
max={10}
value={settings.max_results || 5}
onChange={(e) =>
updateDraft((current) => ({
...current,
settings: {
...current.settings,
[providerId]: {
...current.settings[providerId],
max_results: Number(e.target.value) || 0,
},
},
}))
}
/>
</div>
{(providerId === "tavily" ||
providerId === "searxng" ||
providerId === "glm_search" ||
providerId === "baidu_search") && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t("pages.agent.tools.web_search.base_url")}
</div>
<Input
value={settings.base_url ?? ""}
onChange={(e) =>
updateDraft((current) => ({
...current,
settings: {
...current.settings,
[providerId]: {
...current.settings[providerId],
base_url: e.target.value,
},
},
}))
}
placeholder={t("pages.agent.tools.web_search.base_url_placeholder")}
/>
</div>
)}
{(providerId === "brave" ||
providerId === "tavily" ||
providerId === "perplexity" ||
providerId === "glm_search" ||
providerId === "baidu_search") && (
<div className="space-y-2">
<div className="text-sm font-medium">
{t("pages.agent.tools.web_search.api_key")}
</div>
<KeyInput
value={settings.api_key ?? ""}
onChange={(value) =>
updateDraft((current) => ({
...current,
settings: {
...current.settings,
[providerId]: {
...current.settings[providerId],
api_key: value,
},
},
}))
}
placeholder={apiKeyPlaceholder}
/>
</div>
)}
</CardContent>
</Card>
)
})}
</div>
<div className="flex justify-end">
<Button
onClick={() => webSearchMutation.mutate(webSearchDraft)}
disabled={webSearchMutation.isPending}
>
{t("pages.agent.tools.web_search.save")}
</Button>
</div>
</CardContent>
</Card>
)}
{/* Header & Description */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-end">
{/* Filters Toolbar */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative">
<IconSearch className="text-muted-foreground absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
type="text"
placeholder={t("pages.agent.tools.search_placeholder")}
className="w-full pl-9 sm:w-64"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full sm:w-40">
<SelectValue
placeholder={t("pages.agent.tools.filter.all")}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("pages.agent.tools.filter.all")}
</SelectItem>
<SelectItem value="enabled">
{t("pages.agent.tools.filter.enabled")}
</SelectItem>
<SelectItem value="disabled">
{t("pages.agent.tools.filter.disabled")}
</SelectItem>
<SelectItem value="blocked">
{t("pages.agent.tools.filter.blocked")}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Content Area */}
{error ? (
<Card className="border-destructive/50 bg-destructive/10 cursor-default">
<CardContent className="py-10 text-center">
<p className="text-destructive font-medium">
{t("pages.agent.load_error")}
</p>
</CardContent>
</Card>
) : isLoading ? (
// Skeleton Loading State
<div className="space-y-8">
{[1, 2].map((groupIndex) => (
<div key={groupIndex} className="space-y-4">
<Skeleton className="h-5 w-32" />
<div className="grid gap-4 lg:grid-cols-2">
{[1, 2, 3, 4].map((itemIndex) => (
<Card
key={itemIndex}
className="border-border/60 shadow-none"
>
<CardHeader className="pb-3">
<Skeleton className="mb-2 h-5 w-48" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-3/4" />
</CardHeader>
<CardContent>
<Skeleton className="mt-2 h-8 w-full rounded-md" />
</CardContent>
</Card>
))}
</div>
</div>
))}
</div>
) : totalFilteredCount === 0 ? (
// Empty State
<Card className="bg-muted/30 cursor-default border-dashed">
<CardContent className="flex flex-col items-center justify-center py-16 text-center text-sm">
<div className="bg-muted mb-4 rounded-full p-4">
<IconSearch className="text-muted-foreground size-8" />
</div>
<h3 className="mb-1 text-lg font-medium">
{data?.tools.length === 0
? t("pages.agent.tools.empty")
: t("pages.agent.tools.no_results")}
</h3>
{data?.tools.length !== 0 && (
<p className="text-muted-foreground">
Try adjusting your search criteria or status filters.
</p>
)}
</CardContent>
</Card>
) : (
// Tool Categories list
<div className="space-y-8">
{groupedTools.map(([category, items]) => (
<div key={category} className="space-y-4">
<h3 className="text-foreground text-sm font-semibold tracking-wide uppercase">
{t(`pages.agent.tools.categories.${category}`)}
</h3>
<div className="grid gap-4 lg:grid-cols-2">
{items.map((tool) => {
const reasonText = tool.reason_code
? t(`pages.agent.tools.reasons.${tool.reason_code}`)
: ""
const isPending =
toggleMutation.isPending &&
toggleMutation.variables?.name === tool.name
const isEnabled = tool.status === "enabled"
const isDisabled = tool.status === "disabled"
const isBlocked = tool.status === "blocked"
return (
<Card
key={tool.name}
className={cn(
"group cursor-default transition-colors",
isBlocked
? "border-amber-200/80 bg-amber-50/60 dark:border-amber-900/50 dark:bg-amber-950/20"
: "border-border/60",
isDisabled && "opacity-80",
)}
>
<CardHeader className="pb-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<CardTitle className="font-mono text-sm font-semibold break-all">
{tool.name}
</CardTitle>
<ToolStatusBadge status={tool.status} />
</div>
<CardDescription className="text-muted-foreground/80 mt-2 text-xs leading-relaxed break-words sm:text-sm">
{tool.description}
</CardDescription>
</div>
<div className="flex shrink-0 items-center pt-1 pl-2 sm:pt-0">
<Switch
checked={isEnabled}
disabled={isPending}
onCheckedChange={(checked) =>
toggleMutation.mutate({
name: tool.name,
enabled: checked,
})
}
/>
</div>
</div>
</CardHeader>
{reasonText && (
<CardContent className="pt-0 pb-4">
<div className="text-xs font-medium text-amber-700 dark:text-amber-400">
{reasonText}
</div>
</CardContent>
)}
</Card>
)
})}
</div>
</div>
))}
</div>
<WebSearchTab
draft={webSearchDraft}
currentProviderLabel={currentProviderLabel}
providerLabelMap={providerLabelMap}
expandedProvider={expandedProvider}
isLoading={isWebSearchLoading}
hasError={hasWebSearchError}
isSaving={isWebSearchSaving}
onSave={saveWebSearchConfig}
onToggleProviderExpand={toggleExpandedProvider}
onUpdateDraft={updateWebSearchDraft}
/>
)}
</div>
</div>
</div>
)
}
function ToolStatusBadge({ status }: { status: ToolSupportItem["status"] }) {
const { t } = useTranslation()
return (
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium tracking-wide sm:text-[11px]",
status === "enabled" &&
"bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-400",
status === "blocked" &&
"bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400",
status === "disabled" && "bg-muted text-muted-foreground",
)}
>
{t(`pages.agent.tools.status.${status}`)}
</span>
)
}

View file

@ -0,0 +1,56 @@
import { useTranslation } from "react-i18next"
import { cn } from "@/lib/utils"
import type { ToolsPageTab } from "./types"
interface ToolsTabsProps {
activeTab: ToolsPageTab
onChange: (tab: ToolsPageTab) => void
}
const tabs: Array<{
defaultLabel: string
key: ToolsPageTab
translationKey: string
}> = [
{
key: "library",
translationKey: "pages.agent.tools.library_title",
defaultLabel: "Tool Library",
},
{
key: "web-search",
translationKey: "pages.agent.tools.web_search.title",
defaultLabel: "Web Search",
},
]
export function ToolsTabs({ activeTab, onChange }: ToolsTabsProps) {
const { t } = useTranslation()
return (
<div className="border-border/60 border-b px-6 pt-2">
<div className="flex gap-8">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => onChange(tab.key)}
className={cn(
"hover:text-foreground relative cursor-pointer pb-4 text-[14px] font-medium transition-colors outline-none",
activeTab === tab.key
? "text-foreground"
: "text-muted-foreground",
)}
>
{t(tab.translationKey, tab.defaultLabel)}
{activeTab === tab.key && (
<span className="bg-primary absolute inset-x-0 bottom-0 h-[2px] rounded-t-full" />
)}
</button>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,9 @@
import type { ToolSupportItem, WebSearchConfigResponse } from "@/api/tools"
export type ToolsPageTab = "library" | "web-search"
export type ToolStatusFilter = "all" | ToolSupportItem["status"]
export type GroupedTools = Array<[string, ToolSupportItem[]]>
export type WebSearchDraftUpdater = (
updater: (current: WebSearchConfigResponse) => WebSearchConfigResponse,
) => void

View file

@ -0,0 +1,194 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useDeferredValue, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import {
getTools,
getWebSearchConfig,
setToolEnabled,
updateWebSearchConfig,
type WebSearchConfigResponse,
} from "@/api/tools"
import { refreshGatewayState } from "@/store/gateway"
import type { GroupedTools, ToolStatusFilter, ToolsPageTab } from "./types"
export function useToolsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState<ToolsPageTab>("library")
const [searchQuery, setSearchQuery] = useState("")
const deferredSearchQuery = useDeferredValue(searchQuery)
const [statusFilter, setStatusFilter] = useState<ToolStatusFilter>("all")
const [expandedProvider, setExpandedProvider] = useState<string | null>(null)
const [webSearchDraftOverride, setWebSearchDraftOverride] =
useState<WebSearchConfigResponse | null>(null)
const toolsQuery = useQuery({
queryKey: ["tools"],
queryFn: getTools,
})
const webSearchQuery = useQuery({
queryKey: ["tools", "web-search-config"],
queryFn: getWebSearchConfig,
})
const tools = useMemo(() => toolsQuery.data?.tools ?? [], [toolsQuery.data?.tools])
const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase()
const webSearchDraft = webSearchDraftOverride ?? webSearchQuery.data ?? null
const toggleToolMutation = useMutation({
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
setToolEnabled(name, enabled),
onSuccess: (_, variables) => {
toast.success(
variables.enabled
? t("pages.agent.tools.enable_success", "Tool enabled successfully")
: t(
"pages.agent.tools.disable_success",
"Tool disabled successfully",
),
)
void queryClient.invalidateQueries({ queryKey: ["tools"] })
void refreshGatewayState({ force: true })
},
onError: (error) => {
toast.error(
error instanceof Error
? error.message
: t("pages.agent.tools.toggle_error", "Failed to toggle tool"),
)
},
})
const saveWebSearchMutation = useMutation({
mutationFn: updateWebSearchConfig,
onSuccess: (updatedConfig) => {
queryClient.setQueryData(["tools", "web-search-config"], updatedConfig)
setWebSearchDraftOverride(null)
toast.success(
t(
"pages.agent.tools.web_search.save_success",
"Settings saved successfully",
),
)
void queryClient.invalidateQueries({
queryKey: ["tools", "web-search-config"],
})
void queryClient.invalidateQueries({ queryKey: ["tools"] })
void refreshGatewayState({ force: true })
},
onError: (error) => {
toast.error(
error instanceof Error
? error.message
: t(
"pages.agent.tools.web_search.save_error",
"Failed to save settings",
),
)
},
})
const groupedTools = useMemo<{
groupedTools: GroupedTools
totalFilteredCount: number
}>(() => {
let totalFilteredCount = 0
const grouped = new Map<string, typeof tools>()
for (const tool of tools) {
if (statusFilter !== "all" && tool.status !== statusFilter) {
continue
}
if (normalizedSearchQuery) {
const matchesName = tool.name.toLowerCase().includes(normalizedSearchQuery)
const matchesDescription = (tool.description || "")
.toLowerCase()
.includes(normalizedSearchQuery)
if (!matchesName && !matchesDescription) {
continue
}
}
totalFilteredCount += 1
const items = grouped.get(tool.category) ?? []
items.push(tool)
grouped.set(tool.category, items)
}
return {
groupedTools: Array.from(grouped.entries()),
totalFilteredCount,
}
}, [normalizedSearchQuery, statusFilter, tools])
const providerLabelMap = useMemo(() => {
const providers = webSearchDraft?.providers ?? []
return new Map(providers.map((provider) => [provider.id, provider.label]))
}, [webSearchDraft])
const currentProviderLabel = webSearchDraft?.current_service
? (providerLabelMap.get(webSearchDraft.current_service) ??
webSearchDraft.current_service)
: t("pages.agent.tools.web_search.none", "None")
const pendingToolName = toggleToolMutation.isPending
? (toggleToolMutation.variables?.name ?? null)
: null
const updateWebSearchDraft = (
updater: (current: WebSearchConfigResponse) => WebSearchConfigResponse,
) => {
setWebSearchDraftOverride((current) => {
const draft = current ?? webSearchQuery.data
return draft ? updater(draft) : current
})
}
const toggleTool = (name: string, enabled: boolean) => {
toggleToolMutation.mutate({ name, enabled })
}
const saveWebSearchConfig = () => {
if (webSearchDraft) {
saveWebSearchMutation.mutate(webSearchDraft)
}
}
const toggleExpandedProvider = (providerId: string) => {
setExpandedProvider((current) =>
current === providerId ? null : providerId,
)
}
return {
activeTab,
currentProviderLabel,
expandedProvider,
groupedTools: groupedTools.groupedTools,
pendingToolName,
providerLabelMap,
searchQuery,
statusFilter,
tools,
totalFilteredCount: groupedTools.totalFilteredCount,
webSearchDraft,
hasToolsError: toolsQuery.error != null,
hasWebSearchError: webSearchQuery.error != null,
isToolsLoading: toolsQuery.isLoading,
isWebSearchLoading: webSearchQuery.isLoading,
isWebSearchSaving: saveWebSearchMutation.isPending,
setActiveTab,
setSearchQuery,
setStatusFilter,
saveWebSearchConfig,
toggleExpandedProvider,
toggleTool,
updateWebSearchDraft,
}
}

View file

@ -0,0 +1,139 @@
import type { ReactNode } from "react"
import { useTranslation } from "react-i18next"
import type { WebSearchConfigResponse } from "@/api/tools"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import type { WebSearchDraftUpdater } from "./types"
interface WebSearchGeneralSettingsProps {
draft: WebSearchConfigResponse
onUpdateDraft: WebSearchDraftUpdater
}
export function WebSearchGeneralSettings({
draft,
onUpdateDraft,
}: WebSearchGeneralSettingsProps) {
const { t } = useTranslation()
return (
<div className="space-y-4">
<h3 className="text-foreground/80 text-[13px] font-bold tracking-widest uppercase">
{t("pages.agent.tools.web_search.global_settings", "General")}
</h3>
<div className="bg-card border-border/40 divide-border/40 divide-y overflow-hidden rounded-2xl border shadow-sm">
<SettingRow
label={t("pages.agent.tools.web_search.provider", "Primary Provider")}
description={t(
"pages.agent.tools.web_search.provider_description",
"Select the default search engine that agents will fallback to.",
)}
>
<Select
value={draft.provider}
onValueChange={(value) =>
onUpdateDraft((current) => ({
...current,
provider: value,
}))
}
>
<SelectTrigger className="bg-muted/40 hover:bg-muted/60 focus:ring-foreground/5 focus:border-border/80 w-full rounded-xl border-transparent shadow-none transition-all sm:w-64">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-border/40 rounded-xl shadow-lg">
{draft.providers.map((provider) => (
<SelectItem
key={provider.id}
value={provider.id}
className="rounded-lg"
>
{provider.label}
</SelectItem>
))}
</SelectContent>
</Select>
</SettingRow>
<SettingRow
label={t(
"pages.agent.tools.web_search.proxy",
"Proxy Configuration",
)}
description={t(
"pages.agent.tools.web_search.proxy_description",
"Optional global HTTP/S proxy for underlying web requests.",
)}
>
<Input
className="bg-muted/40 hover:bg-muted/60 focus-visible:bg-background focus-visible:border-border/80 focus-visible:ring-foreground/5 w-full rounded-xl border-transparent shadow-none transition-all duration-300 sm:w-64"
value={draft.proxy ?? ""}
onChange={(event) =>
onUpdateDraft((current) => ({
...current,
proxy: event.target.value,
}))
}
placeholder="http://127.0.0.1:7890"
/>
</SettingRow>
<SettingRow
label={t(
"pages.agent.tools.web_search.prefer_native",
"Prefer Native Search",
)}
description={t(
"pages.agent.tools.web_search.prefer_native_hint",
"Bypass external providers if the agent inherently supports web search tools.",
)}
>
<Switch
checked={draft.prefer_native}
onCheckedChange={(checked) =>
onUpdateDraft((current) => ({
...current,
prefer_native: checked,
}))
}
className="data-[state=checked]:shadow-xs"
/>
</SettingRow>
</div>
</div>
)
}
function SettingRow({
label,
description,
children,
}: {
label: string
description: string
children: ReactNode
}) {
return (
<div className="hover:bg-muted/10 flex flex-col justify-between gap-4 p-5 transition-colors sm:flex-row sm:items-center">
<div className="w-full space-y-1 sm:max-w-md">
<label className="text-foreground/90 text-[15px] font-semibold tracking-tight">
{label}
</label>
<p className="text-muted-foreground/80 text-[13px] leading-relaxed">
{description}
</p>
</div>
{children}
</div>
)
}

View file

@ -0,0 +1,253 @@
import { IconChevronDown } from "@tabler/icons-react"
import type { ReactNode } from "react"
import { useTranslation } from "react-i18next"
import type { WebSearchProviderConfig } from "@/api/tools"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { KeyInput } from "@/components/shared-form"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import type { WebSearchDraftUpdater } from "./types"
interface WebSearchProviderSettingsProps {
providerLabelMap: Map<string, string>
settings: Record<string, WebSearchProviderConfig>
expandedProvider: string | null
onToggleProviderExpand: (providerId: string) => void
onUpdateDraft: WebSearchDraftUpdater
}
const baseUrlProviders = new Set([
"tavily",
"searxng",
"glm_search",
"baidu_search",
])
const apiKeyProviders = new Set([
"brave",
"tavily",
"perplexity",
"glm_search",
"baidu_search",
])
export function WebSearchProviderSettings({
providerLabelMap,
settings,
expandedProvider,
onToggleProviderExpand,
onUpdateDraft,
}: WebSearchProviderSettingsProps) {
const { t } = useTranslation()
return (
<div className="space-y-4">
<h3 className="text-foreground/80 text-[13px] font-bold tracking-widest uppercase">
{t("pages.agent.tools.web_search.providers_config", "Integrations")}
</h3>
<div className="bg-card border-border/40 divide-border/40 divide-y overflow-hidden rounded-2xl border shadow-sm">
{Object.entries(settings).map(([providerId, providerSettings]) => (
<ProviderCard
key={providerId}
providerId={providerId}
providerLabel={providerLabelMap.get(providerId) ?? providerId}
settings={providerSettings}
isExpanded={expandedProvider === providerId}
onToggleExpand={onToggleProviderExpand}
onUpdateDraft={onUpdateDraft}
/>
))}
</div>
</div>
)
}
function ProviderCard({
providerId,
providerLabel,
settings,
isExpanded,
onToggleExpand,
onUpdateDraft,
}: {
providerId: string
providerLabel: string
settings: WebSearchProviderConfig
isExpanded: boolean
onToggleExpand: (providerId: string) => void
onUpdateDraft: WebSearchDraftUpdater
}) {
const { t } = useTranslation()
const apiKeyPlaceholder = maskedSecretPlaceholder(
settings.api_key_set ? `${providerId}-configured` : "",
t(
"pages.agent.tools.web_search.api_key_placeholder",
"Enter API key...",
),
)
const updateSettings = (
updater: (current: WebSearchProviderConfig) => WebSearchProviderConfig,
) => {
onUpdateDraft((current) => {
const nextSettings = current.settings[providerId] ?? settings
return {
...current,
settings: {
...current.settings,
[providerId]: updater(nextSettings),
},
}
})
}
return (
<div
className={cn(
"group flex flex-col transition-colors",
isExpanded ? "bg-muted/5" : "hover:bg-muted/20",
)}
>
<div className="flex items-center justify-between gap-4 p-5">
<button
type="button"
className="flex min-w-0 flex-1 cursor-pointer items-center gap-4 text-left select-none"
aria-expanded={isExpanded}
aria-controls={`web-search-provider-${providerId}`}
onClick={() => onToggleExpand(providerId)}
>
<div
className={cn(
"text-muted-foreground flex items-center justify-center transition-transform duration-300",
isExpanded && "rotate-180",
)}
>
<IconChevronDown className="size-[18px]" />
</div>
<div className="flex items-center gap-3">
<span className="text-foreground/90 text-[15px] font-semibold tracking-tight">
{providerLabel}
</span>
{settings.enabled ? (
<span className="inline-block rounded-md bg-emerald-500/10 px-2 py-0.5 text-[10px] font-bold tracking-wider text-emerald-600 uppercase dark:text-emerald-400">
{t("pages.agent.tools.filter.enabled", "Enabled")}
</span>
) : (
<span className="bg-muted text-muted-foreground/70 inline-block rounded-md px-2 py-0.5 text-[10px] font-bold tracking-wider uppercase">
{t("pages.agent.tools.filter.disabled", "Disabled")}
</span>
)}
</div>
</button>
<div
className="flex items-center gap-4"
onClick={(event) => event.stopPropagation()}
>
<Switch
checked={settings.enabled}
onCheckedChange={(checked) =>
updateSettings((current) => ({
...current,
enabled: checked,
}))
}
/>
</div>
</div>
{isExpanded && (
<div
id={`web-search-provider-${providerId}`}
className="animate-in fade-in slide-in-from-top-1 border-border/10 border-t px-6 pt-1 pb-6 duration-200"
>
<div className="ml-8 flex max-w-xl flex-col gap-5">
<ProviderField
label={t("pages.agent.tools.web_search.max_results", "Max Results")}
>
<Input
type="number"
min={1}
max={10}
value={settings.max_results || 5}
onChange={(event) =>
updateSettings((current) => ({
...current,
max_results: Number(event.target.value) || 0,
}))
}
className="bg-muted/40 hover:bg-muted/60 focus:bg-background focus:ring-primary/20 h-10 rounded-xl border-transparent shadow-none transition-colors"
/>
</ProviderField>
{baseUrlProviders.has(providerId) && (
<ProviderField
label={t("pages.agent.tools.web_search.base_url", "Base URL")}
>
<Input
value={settings.base_url ?? ""}
onChange={(event) =>
updateSettings((current) => ({
...current,
base_url: event.target.value,
}))
}
placeholder={t(
"pages.agent.tools.web_search.base_url_placeholder",
"Optional endpoint override",
)}
className="bg-muted/40 hover:bg-muted/60 focus:bg-background focus:ring-primary/20 h-10 rounded-xl border-transparent shadow-none transition-colors"
/>
</ProviderField>
)}
{apiKeyProviders.has(providerId) && (
<ProviderField
label={t(
"pages.agent.tools.web_search.api_key",
"API Key / Token",
)}
className="pt-1"
>
<KeyInput
value={settings.api_key ?? ""}
onChange={(value) =>
updateSettings((current) => ({
...current,
api_key: value,
}))
}
placeholder={apiKeyPlaceholder}
className="bg-muted/40 hover:bg-muted/60 focus:bg-background focus:ring-primary/20 h-10 rounded-xl border-transparent transition-colors"
/>
</ProviderField>
)}
</div>
</div>
)}
</div>
)
}
function ProviderField({
label,
className,
children,
}: {
label: string
className?: string
children: ReactNode
}) {
return (
<div className={cn("space-y-1.5", className)}>
<label className="text-foreground/80 text-[13px] font-semibold">
{label}
</label>
{children}
</div>
)
}

View file

@ -0,0 +1,109 @@
import { useTranslation } from "react-i18next"
import type { WebSearchConfigResponse } from "@/api/tools"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import type { WebSearchDraftUpdater } from "./types"
import { WebSearchGeneralSettings } from "./web-search-general-settings"
import { WebSearchProviderSettings } from "./web-search-provider-settings"
interface WebSearchTabProps {
draft: WebSearchConfigResponse | null
currentProviderLabel: string
providerLabelMap: Map<string, string>
expandedProvider: string | null
isLoading: boolean
hasError: boolean
isSaving: boolean
onSave: () => void
onToggleProviderExpand: (providerId: string) => void
onUpdateDraft: WebSearchDraftUpdater
}
export function WebSearchTab({
draft,
currentProviderLabel,
providerLabelMap,
expandedProvider,
isLoading,
hasError,
isSaving,
onSave,
onToggleProviderExpand,
onUpdateDraft,
}: WebSearchTabProps) {
const { t } = useTranslation()
return (
<div className="animate-in fade-in slide-in-from-bottom-2 space-y-12 pt-2 duration-500">
{hasError ? (
<div className="py-20 text-center">
<p className="text-destructive font-medium">
{t(
"pages.agent.tools.web_search.load_error",
"Failed to load web search configuration",
)}
</p>
</div>
) : isLoading || !draft ? (
<LoadingState />
) : (
<>
<div className="flex flex-col gap-6 sm:flex-row sm:items-start sm:justify-between">
<div className="max-w-xl space-y-3">
<div className="flex items-center gap-3">
<h1 className="text-foreground/90 text-2xl font-semibold tracking-tight">
{t(
"pages.agent.tools.web_search.title",
"Web Search Configuration",
)}
</h1>
<div className="rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-[11px] font-semibold tracking-wide text-emerald-600 uppercase dark:text-emerald-400">
{currentProviderLabel}
</div>
</div>
<p className="text-muted-foreground/80 text-[14px] leading-relaxed">
{t(
"pages.agent.tools.web_search.description",
"Provide web search capability for agents to find the latest real-world info. Automatically routes to the optimal active provider.",
)}
</p>
</div>
<Button
onClick={onSave}
disabled={isSaving}
className="h-10 shrink-0 rounded-xl px-6 shadow-sm transition-all active:scale-95"
>
{t("pages.agent.tools.web_search.save", "Save Changes")}
</Button>
</div>
<div className="space-y-10">
<WebSearchGeneralSettings
draft={draft}
onUpdateDraft={onUpdateDraft}
/>
<WebSearchProviderSettings
providerLabelMap={providerLabelMap}
settings={draft.settings}
expandedProvider={expandedProvider}
onToggleProviderExpand={onToggleProviderExpand}
onUpdateDraft={onUpdateDraft}
/>
</div>
</>
)}
</div>
)
}
function LoadingState() {
return (
<div className="space-y-8">
<Skeleton className="h-24 rounded-2xl" />
<Skeleton className="h-64 rounded-2xl" />
</div>
)
}

View file

@ -48,6 +48,14 @@ function asBool(value: unknown): boolean {
return value === true
}
const CHANNEL_COMMON_CONFIG_KEYS = new Set([
"allow_from",
"group_trigger",
"placeholder",
"reasoning_channel_id",
"typing",
])
function normalizeConfig(
channel: SupportedChannel,
rawConfig: ChannelConfig,
@ -67,33 +75,42 @@ function buildSavePayload(
editConfig: ChannelConfig,
enabled: boolean,
): ChannelConfig {
const payload: ChannelConfig = { enabled }
const payload: ChannelConfig = { enabled, type: channel.config_key }
const settings: ChannelConfig = {}
for (const [key, value] of Object.entries(editConfig)) {
if (key.startsWith("_")) continue
if (key === "enabled") continue
if (CHANNEL_COMMON_CONFIG_KEYS.has(key)) {
payload[key] = value
continue
}
if (isSecretField(key)) continue
payload[key] = value
settings[key] = value
}
for (const [secretKey, editKey] of Object.entries(SECRET_FIELD_MAP)) {
const incoming = asString(editConfig[editKey])
if (incoming !== "") {
payload[secretKey] = incoming
settings[secretKey] = incoming
continue
}
const existing = asString(editConfig[secretKey]).trim()
if (existing !== "") {
payload[secretKey] = existing
settings[secretKey] = existing
}
}
if (channel.name === "whatsapp_native") {
payload.use_native = true
settings.use_native = true
}
if (channel.name === "whatsapp") {
payload.use_native = false
settings.use_native = false
}
if (Object.keys(settings).length > 0) {
payload.settings = settings
}
return payload
@ -377,7 +394,7 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) {
setFieldErrors({})
try {
await patchAppConfig({
channels: {
channel_list: {
[channel.config_key]: savePayload,
},
})

View file

@ -130,9 +130,10 @@ export function WecomForm({
setToggleError("")
try {
await patchAppConfig({
channels: {
channel_list: {
wecom: {
enabled: checked,
type: "wecom",
},
},
})

View file

@ -90,9 +90,10 @@ interface KeyInputProps {
value: string
onChange: (v: string) => void
placeholder?: string
className?: string
}
export function KeyInput({ value, onChange, placeholder }: KeyInputProps) {
export function KeyInput({ value, onChange, placeholder, className }: KeyInputProps) {
const [show, setShow] = useState(false)
return (
@ -102,7 +103,7 @@ export function KeyInput({ value, onChange, placeholder }: KeyInputProps) {
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className="pr-10"
className={cn("pr-10", className)}
/>
<button
type="button"

View file

@ -525,32 +525,39 @@
"no_results": "No tools match your criteria.",
"filter": {
"all": "All Status",
"enabled": "Enabled only",
"disabled": "Disabled only",
"blocked": "Blocked only"
"enabled": "Enabled",
"disabled": "Disabled",
"blocked": "Blocked"
},
"empty": "No tools are available.",
"enable_success": "Tool enabled.",
"disable_success": "Tool disabled.",
"toggle_error": "Failed to update tool state.",
"library_title": "Tool Library",
"library_description": "Browse and manage the toolset available to your AI agents.",
"web_search": {
"title": "Web Search Service",
"description": "Choose the default web search backend and configure supported providers.",
"title": "Web Search",
"description": "Provide web search capability for agents to find the latest real-world info. Automatically routes to the optimal active provider.",
"global_settings": "General",
"providers_config": "Integrations",
"load_error": "Failed to load web search configuration.",
"save": "Save Web Search Settings",
"save_success": "Web search configuration updated.",
"save_error": "Failed to update web search configuration.",
"save": "Save Changes",
"save_success": "Settings saved successfully.",
"save_error": "Failed to save settings.",
"current_active": "Active: ",
"current_service": "Current Service",
"provider": "Preferred Provider",
"proxy": "Proxy",
"prefer_native": "Prefer Provider Native Search",
"prefer_native_hint": "When the active model supports built-in web search, prefer that capability over the client-side tool.",
"provider": "Primary Provider",
"provider_description": "Select the default search engine that agents will fallback to.",
"proxy": "HTTPS Proxy",
"proxy_description": "Optional global HTTP/S proxy for underlying web requests.",
"prefer_native": "Prefer Native Search",
"prefer_native_hint": "Bypass external providers if the agent inherently supports web search tools.",
"provider_hint": "Enable this provider and fill any required connection settings.",
"max_results": "Max Results",
"base_url": "Base URL",
"base_url_placeholder": "https://api.example.com/search",
"api_key": "API Key",
"api_key_placeholder": "Leave blank to keep the existing key",
"base_url_placeholder": "Optional endpoint override",
"api_key": "API Key / Token",
"api_key_placeholder": "Enter API key, leave it blank to keep the original key",
"none": "Unavailable"
},
"status": {

View file

@ -533,25 +533,32 @@
"enable_success": "工具已启用。",
"disable_success": "工具已禁用。",
"toggle_error": "更新工具状态失败。",
"library_title": "工具库",
"library_description": "浏览并管理由您的 AI 智能体支持的集成工具。",
"web_search": {
"title": "Web Search 服务",
"description": "选择默认网页搜索后端,并配置已支持的搜索服务。",
"title": "网页搜索",
"description": "为智能体提供网页搜索能力。自动路由到当前处于激活状态的最佳服务。",
"global_settings": "常规",
"providers_config": "集成",
"load_error": "加载 Web Search 配置失败。",
"save": "保存 Web Search 配置",
"save_success": "Web Search 配置已更新。",
"save_error": "更新 Web Search 配置失败。",
"save": "保存更改",
"save_success": "设置保存成功。",
"save_error": "保存设置失败。",
"current_active": "活动: ",
"current_service": "当前服务",
"provider": "首选服务",
"proxy": "代理",
"prefer_native": "优先使用模型原生搜索",
"prefer_native_hint": "如果当前模型支持内建网页搜索,优先使用模型原生能力而不是客户端工具。",
"provider_description": "选择智能体在默认情况下进行网络搜索的回退引擎。",
"proxy": "HTTPS 代理",
"proxy_description": "用于底层网页请求的可选全局代理配置。",
"prefer_native": "优先使用模型搜索",
"prefer_native_hint": "如果当前模型本身支持联网功能,则直接使用模型自带的搜索能力",
"provider_hint": "启用该服务后,可继续填写所需的连接参数。",
"max_results": "最大结果数",
"base_url": "基础 URL",
"base_url_placeholder": "https://api.example.com/search",
"api_key": "API Key",
"api_key_placeholder": "留空则保留现有密钥",
"none": "不可用"
"max_results": "最大获取结果数",
"base_url": "API 请求地址",
"base_url_placeholder": "可选,如果需要代理请覆盖终端地址",
"api_key": "API 密钥 (Token)",
"api_key_placeholder": "请输入密钥,留空则保持原密钥不变",
"none": "未配置"
},
"status": {
"enabled": "已启用",