diff --git a/.github/workflows/create_dmg.yml b/.github/workflows/create_dmg.yml
index b47247fc2..d0a820944 100644
--- a/.github/workflows/create_dmg.yml
+++ b/.github/workflows/create_dmg.yml
@@ -54,9 +54,9 @@ jobs:
"dist/picoclaw-${{ matrix.arch }}.dmg" \
"build/PicoClaw Launcher.app"
- # 6. 上传文件到 GitHub Artifacts (供你下载)
+ # 7. 上传文件到 GitHub Artifacts (供你下载)
- name: Upload DMG
uses: actions/upload-artifact@v4
with:
name: macos-dmg-${{ matrix.arch }}
- path: dist/*.dmg
\ No newline at end of file
+ path: dist/*.dmg
diff --git a/README.fr.md b/README.fr.md
index a0cb84ce3..a26c89f14 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -306,7 +306,25 @@ Pour la documentation détaillée du TUI, voir [docs.picoclaw.io](https://docs.p
Donnez une seconde vie à votre téléphone vieux de dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw.
-**Option 1 : Termux (disponible maintenant)**
+**Option 1 : Installation APK**
+
+Aperçu :
+
+
+
+Téléchargez l'APK depuis [picoclaw.io](https://picoclaw.io/download/) et installez-le directement. Pas besoin de Termux !
+
+**Option 2 : Termux**
+
+
+Terminal Launcher (pour les environnements à ressources limitées)
1. Installez [Termux](https://github.com/termux/termux-app) (téléchargez depuis [GitHub Releases](https://github.com/termux/termux-app/releases), ou cherchez dans F-Droid / Google Play)
2. Exécutez les commandes suivantes :
@@ -323,13 +341,6 @@ Suivez ensuite la section Terminal Launcher ci-dessous pour terminer la configur
-**Option 2 : Installation APK**
-
-Téléchargez l'APK depuis [picoclaw.io](https://picoclaw.io/download/) et installez-le directement. Pas besoin de Termux !
-
-
-Terminal Launcher (pour les environnements à ressources limitées)
-
Pour les environnements minimaux où seul le binaire principal `picoclaw` est disponible (sans Launcher UI), vous pouvez tout configurer via la ligne de commande et un fichier de configuration JSON.
**1. Initialiser**
diff --git a/README.id.md b/README.id.md
index bba010dec..d3c556dde 100644
--- a/README.id.md
+++ b/README.id.md
@@ -303,7 +303,25 @@ Untuk dokumentasi TUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io
Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw.
-**Opsi 1: Termux (tersedia sekarang)**
+**Opsi 1: Instal APK**
+
+Pratinjau:
+
+
+
+Unduh APK dari [picoclaw.io](https://picoclaw.io/download/) dan instal langsung. Tanpa Termux!
+
+**Opsi 2: Termux**
+
+
+Terminal Launcher (untuk lingkungan dengan sumber daya terbatas)
1. Instal [Termux](https://github.com/termux/termux-app) (unduh dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play)
2. Jalankan perintah berikut:
@@ -320,13 +338,6 @@ Kemudian ikuti bagian Terminal Launcher di bawah untuk menyelesaikan konfigurasi
-**Opsi 2: Instal APK**
-
-Unduh APK dari [picoclaw.io](https://picoclaw.io/download/) dan instal langsung. Tanpa Termux!
-
-
-Terminal Launcher (untuk lingkungan dengan sumber daya terbatas)
-
Untuk lingkungan minimal di mana hanya binary inti `picoclaw` yang tersedia (tanpa Launcher UI), Anda dapat mengonfigurasi semuanya melalui command line dan file konfigurasi JSON.
**1. Inisialisasi**
diff --git a/README.it.md b/README.it.md
index 50f08ad8b..6fe6c5e17 100644
--- a/README.it.md
+++ b/README.it.md
@@ -303,7 +303,25 @@ Per la documentazione dettagliata del TUI, vedi [docs.picoclaw.io](https://docs.
Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw.
-**Opzione 1: Termux (disponibile ora)**
+**Opzione 1: Installazione APK**
+
+Anteprima:
+
+
+
+Scarica l'APK da [picoclaw.io](https://picoclaw.io/download/) e installa direttamente. Senza Termux!
+
+**Opzione 2: Termux**
+
+
+Terminal Launcher (per ambienti con risorse limitate)
1. Installa [Termux](https://github.com/termux/termux-app) (scarica da [GitHub Releases](https://github.com/termux/termux-app/releases), o cerca su F-Droid / Google Play)
2. Esegui i seguenti comandi:
@@ -320,13 +338,6 @@ Poi segui la sezione Terminal Launcher qui sotto per completare la configurazion
-**Opzione 2: Installazione APK**
-
-Scarica l'APK da [picoclaw.io](https://picoclaw.io/download/) e installa direttamente. Senza Termux!
-
-
-Terminal Launcher (per ambienti con risorse limitate)
-
Per ambienti minimali dove è disponibile solo il binario core `picoclaw` (senza Launcher UI), puoi configurare tutto tramite riga di comando e un file di configurazione JSON.
**1. Inizializza**
diff --git a/README.ja.md b/README.ja.md
index 7171a87b9..793c41fcb 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -303,7 +303,25 @@ TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.i
10 年前のスマホに第二の人生を!PicoClaw でスマート AI アシスタントに変身させましょう。
-**オプション 1: Termux(現在利用可能)**
+**オプション 1: APK インストール**
+
+プレビュー:
+
+
+
+[picoclaw.io](https://picoclaw.io/download/) から APK をダウンロードして直接インストール。Termux 不要!
+
+**オプション 2: Termux**
+
+
+Terminal Launcher(リソース制約環境向け)
1. [Termux](https://github.com/termux/termux-app) をインストール([GitHub Releases](https://github.com/termux/termux-app/releases) からダウンロード、または F-Droid / Google Play で検索)
2. 以下のコマンドを実行:
@@ -320,13 +338,6 @@ termux-chroot ./picoclaw onboard # chroot で標準的な Linux ファイル
-**オプション 2: APK インストール**
-
-[picoclaw.io](https://picoclaw.io/download/) から APK をダウンロードして直接インストール。Termux 不要!
-
-
-Terminal Launcher(リソース制約環境向け)
-
`picoclaw` コアバイナリのみが利用可能な最小環境(Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。
**1. 初期化**
diff --git a/README.md b/README.md
index db38e644f..d73348554 100644
--- a/README.md
+++ b/README.md
@@ -303,7 +303,25 @@ For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io)
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw.
-**Option 1: Termux (available now)**
+**Option 1: APK Install**
+
+Preview:
+
+
+
+Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install directly. No Termux required!
+
+**Option 2: Termux**
+
+
+Terminal Launcher (for resource-constrained environments)
1. Install [Termux](https://github.com/termux/termux-app) (download from [GitHub Releases](https://github.com/termux/termux-app/releases), or search in F-Droid / Google Play)
2. Run the following commands:
@@ -320,13 +338,6 @@ Then follow the Terminal Launcher section below to complete configuration.
-**Option 2: APK Install**
-
-Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install directly. No Termux required!
-
-
-Terminal Launcher (for resource-constrained environments)
-
For minimal environments where only the `picoclaw` core binary is available (no Launcher UI), you can configure everything via the command line and a JSON config file.
**1. Initialize**
diff --git a/README.my.md b/README.my.md
index 095d4b66a..f00fb438c 100644
--- a/README.my.md
+++ b/README.my.md
@@ -300,7 +300,25 @@ Untuk dokumentasi TUI terperinci, lihat [docs.picoclaw.io](https://docs.picoclaw
Berikan telefon lama anda kehidupan baru! Jadikannya Pembantu AI pintar dengan PicoClaw.
-**Pilihan 1: Termux (tersedia sekarang)**
+**Pilihan 1: Pasang APK**
+
+Pratonton:
+
+
+
+Muat turun APK dari [picoclaw.io](https://picoclaw.io/download/) dan pasang secara langsung. Tiada Termux diperlukan!
+
+**Pilihan 2: Termux**
+
+
+Pelancar Terminal (untuk persekitaran terhad sumber)
1. Pasang [Termux](https://github.com/termux/termux-app) (muat turun dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play)
2. Jalankan arahan berikut:
@@ -317,13 +335,6 @@ Kemudian ikuti bahagian Pelancar Terminal di bawah untuk melengkapkan konfiguras
-**Pilihan 2: Pasang APK**
-
-Muat turun APK dari [picoclaw.io](https://picoclaw.io/download/) dan pasang secara langsung. Tiada Termux diperlukan!
-
-
-Pelancar Terminal (untuk persekitaran terhad sumber)
-
Untuk persekitaran minimal di mana hanya binari teras `picoclaw` tersedia (tiada UI Pelancar), anda boleh mengkonfigurasi semua melalui baris arahan dan fail konfigurasi JSON.
**1. Mulakan**
diff --git a/README.pt-br.md b/README.pt-br.md
index bbc5b4957..db11d4d82 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -303,7 +303,25 @@ Para documentação detalhada do TUI, veja [docs.picoclaw.io](https://docs.picoc
Dê uma segunda vida ao seu celular de uma década! Transforme-o em um Assistente de IA inteligente com o PicoClaw.
-**Opção 1: Termux (disponível agora)**
+**Opção 1: Instalação via APK**
+
+Pré-visualização:
+
+
+
+Baixe o APK de [picoclaw.io](https://picoclaw.io/download/) e instale diretamente. Sem necessidade de Termux!
+
+**Opção 2: Termux**
+
+
+Terminal Launcher (para ambientes com recursos limitados)
1. Instale o [Termux](https://github.com/termux/termux-app) (baixe nas [GitHub Releases](https://github.com/termux/termux-app/releases), ou pesquise no F-Droid / Google Play)
2. Execute os seguintes comandos:
@@ -320,13 +338,6 @@ Em seguida, siga a seção Terminal Launcher abaixo para concluir a configuraç
-**Opção 2: Instalação via APK**
-
-Baixe o APK de [picoclaw.io](https://picoclaw.io/download/) e instale diretamente. Sem necessidade de Termux!
-
-
-Terminal Launcher (para ambientes com recursos limitados)
-
Para ambientes mínimos onde apenas o binário principal `picoclaw` está disponível (sem Launcher UI), você pode configurar tudo via linha de comando e um arquivo de configuração JSON.
**1. Inicializar**
diff --git a/README.vi.md b/README.vi.md
index 7ae414723..78b8a9a59 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -303,7 +303,25 @@ Sử dụng menu TUI để: **1)** Cấu hình Provider -> **2)** Cấu hình Ch
Hãy cho chiếc điện thoại cũ của bạn một cuộc sống mới! Biến nó thành Trợ lý AI thông minh với PicoClaw.
-**Tùy chọn 1: Termux (có sẵn ngay)**
+**Tùy chọn 1: Cài đặt APK**
+
+Xem trước:
+
+
+
+Tải APK từ [picoclaw.io](https://picoclaw.io/download/) và cài đặt trực tiếp. Không cần Termux!
+
+**Tùy chọn 2: Termux**
+
+
+Terminal Launcher (cho môi trường hạn chế tài nguyên)
1. Cài đặt [Termux](https://github.com/termux/termux-app) (tải từ [GitHub Releases](https://github.com/termux/termux-app/releases), hoặc tìm kiếm trong F-Droid / Google Play)
2. Chạy các lệnh sau:
@@ -320,13 +338,6 @@ Sau đó làm theo phần Terminal Launcher bên dưới để hoàn tất cấu
-**Tùy chọn 2: Cài đặt APK**
-
-Tải APK từ [picoclaw.io](https://picoclaw.io/download/) và cài đặt trực tiếp. Không cần Termux!
-
-
-Terminal Launcher (cho môi trường hạn chế tài nguyên)
-
Đối với các môi trường tối giản chỉ có binary lõi `picoclaw` (không có Launcher UI), bạn có thể cấu hình mọi thứ qua dòng lệnh và tệp cấu hình JSON.
**1. Khởi tạo**
diff --git a/README.zh.md b/README.zh.md
index 569ca1656..16d01b59b 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -303,7 +303,25 @@ picoclaw-launcher-tui
让你十年前的旧手机焕发新生!将它变成你的 AI 助手。
-**方式一:Termux(现已可用)**
+**方式一:APK 安装**
+
+预览:
+
+
+
+从 [picoclaw.io](https://picoclaw.io/download/) 下载 APK 并直接安装,无需 Termux!
+
+**方式二:Termux**
+
+
+Terminal Launcher(适用于资源受限环境)
1. 安装 [Termux](https://github.com/termux/termux-app)(可从 [GitHub Releases](https://github.com/termux/termux-app/releases) 下载,或在 F-Droid / Google Play 中搜索)
2. 执行以下命令:
@@ -320,13 +338,6 @@ termux-chroot ./picoclaw onboard # chroot 提供标准 Linux 文件系统布
-**方式二:APK 安装**
-
-从 [picoclaw.io](https://picoclaw.io/download/) 下载 APK 并直接安装,无需 Termux!
-
-
-Terminal Launcher(适用于资源受限环境)
-
对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI),可通过命令行和 JSON 配置文件完成所有配置。
**1. 初始化**
diff --git a/assets/fui_log_page.jpg b/assets/fui_log_page.jpg
new file mode 100644
index 000000000..188c46982
Binary files /dev/null and b/assets/fui_log_page.jpg differ
diff --git a/assets/fui_main_page.jpg b/assets/fui_main_page.jpg
new file mode 100644
index 000000000..f9c5b5c34
Binary files /dev/null and b/assets/fui_main_page.jpg differ
diff --git a/assets/fui_setting_page.jpg b/assets/fui_setting_page.jpg
new file mode 100644
index 000000000..3481088e3
Binary files /dev/null and b/assets/fui_setting_page.jpg differ
diff --git a/assets/fui_web_page.jpg b/assets/fui_web_page.jpg
new file mode 100644
index 000000000..7db04814f
Binary files /dev/null and b/assets/fui_web_page.jpg differ
diff --git a/config/config.example.json b/config/config.example.json
index bedd543d7..f0cce6d72 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -421,7 +421,8 @@
"enabled": true
},
"read_file": {
- "enabled": true
+ "enabled": true,
+ "mode": "bytes"
},
"send_tts": {
"enabled": false
diff --git a/docs/configuration.md b/docs/configuration.md
index 58930cbfa..7a5902f58 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -301,6 +301,66 @@ Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous
| `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace |
| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace |
+### Read File Mode
+
+`read_file` has two mutually exclusive implementations selected by config. PicoClaw registers exactly one of them at startup:
+
+| Config Key | Type | Default | Description |
+|------------|------|---------|-------------|
+| `tools.read_file.enabled` | bool | `true` | Enables the `read_file` tool |
+| `tools.read_file.mode` | string | `bytes` | Selects the `read_file` implementation: `bytes` or `lines` |
+| `tools.read_file.max_read_file_size` | int | `65536` | Maximum bytes returned by `read_file` |
+
+#### Mode: `bytes`
+
+Optimized for arbitrary files and binary-safe pagination.
+
+Parameters:
+
+* `path` (required): File path
+* `offset` (optional): Starting byte offset, default `0`
+* `length` (optional): Maximum number of bytes to read, default `max_read_file_size`
+
+Use `bytes` when:
+
+* You may read binary files
+* You want deterministic byte-range pagination
+
+#### Mode: `lines`
+
+Text-oriented behavior, optimized for source files, markdown, logs, and configs. The tool reads sequentially by line and stops when the configured byte budget is reached.
+
+Parameters:
+
+* `path` (required): File path
+* `start_line` (optional): Starting line number, 1-indexed and inclusive, default `1`
+* `max_lines` (optional): Maximum number of lines to read, default = all remaining lines until EOF or byte budget
+
+Behavior notes:
+
+* Binary-looking files are rejected with guidance to switch `read_file` to `mode = bytes`
+* Extremely long single lines are truncated rather than skipped
+
+Use `mode = lines` when:
+
+* The agent mostly reads text files
+* You want line-based pagination in prompts and tool calls
+* You want cleaner chunks for code review, logs, and documentation
+
+#### Example
+
+```json
+{
+ "tools": {
+ "read_file": {
+ "enabled": true,
+ "mode": "lines",
+ "max_read_file_size": 65536
+ }
+ }
+}
+```
+
### Exec Security
| Config Key | Type | Default | Description |
diff --git a/docs/fr/providers.md b/docs/fr/providers.md
index d0da81897..3305ec5ee 100644
--- a/docs/fr/providers.md
+++ b/docs/fr/providers.md
@@ -99,6 +99,24 @@ Cette conception permet également le **support multi-agents** avec une sélecti
}
```
+#### Champs d'entrée `model_list`
+
+| Champ | Type | Requis | Description |
+|-------|------|--------|-------------|
+| `model_name` | string | Oui | Nom unique pour référencer ce modèle dans la config agent |
+| `model` | string | Oui | Identifiant fournisseur/modèle (ex : `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) |
+| `api_keys` | string[] | Oui* | Clé(s) API pour l'authentification. Plusieurs clés permettent la rotation par requête. Non requis pour les fournisseurs locaux (Ollama, LM Studio, VLLM) |
+| `api_base` | string | Non | Remplace l'URL de base API par défaut |
+| `proxy` | string | Non | URL du proxy HTTP pour cette entrée de modèle |
+| `user_agent` | string | Non | En-tête `User-Agent` personnalisé pour les requêtes API (supporté par les providers OpenAI-compatible, Anthropic et Azure) |
+| `request_timeout` | int | Non | Délai d'expiration de la requête en secondes (la valeur par défaut varie selon le provider) |
+| `max_tokens_field` | string | Non | Remplace le nom du champ max tokens dans le corps de la requête (ex : `max_completion_tokens` pour les modèles o1) |
+| `thinking_level` | string | Non | Niveau de pensée étendue : `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` |
+| `extra_body` | object | Non | Champs supplémentaires à injecter dans chaque corps de requête |
+| `rpm` | int | Non | Limite de requêtes par minute |
+| `fallbacks` | string[] | Non | Noms des modèles de secours pour le basculement automatique |
+| `enabled` | bool | Non | Activer ou désactiver cette entrée de modèle (par défaut : `true`) |
+
#### Exemples par Vendor
**OpenAI**
@@ -190,6 +208,7 @@ Pour l'accès direct à l'API Anthropic ou les endpoints personnalisés qui ne p
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
"api_keys": ["sk-..."],
+ "user_agent": "MyApp/1.0",
"request_timeout": 300
}
```
diff --git a/docs/it/configuration.md b/docs/it/configuration.md
deleted file mode 100644
index 6a79a9543..000000000
--- a/docs/it/configuration.md
+++ /dev/null
@@ -1,219 +0,0 @@
-# ⚙️ Guida alla Configurazione
-
-> Torna al [README](../../README.md)
-
-## ⚙️ Configurazione
-
-File di configurazione: `~/.picoclaw/config.json`
-
-### Variabili d'Ambiente
-
-Puoi sovrascrivere i percorsi predefiniti usando variabili d'ambiente. Questo è utile per installazioni portatili, distribuzioni containerizzate, o per eseguire picoclaw come servizio di sistema. Queste variabili sono indipendenti e controllano percorsi diversi.
-
-| Variabile | Descrizione | Percorso Predefinito |
-|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
-| `PICOCLAW_CONFIG` | Sovrascrive il percorso al file di configurazione. Indica direttamente a picoclaw quale `config.json` caricare, ignorando tutte le altre posizioni. | `~/.picoclaw/config.json` |
-| `PICOCLAW_HOME` | Sovrascrive la directory radice per i dati di picoclaw. Modifica la posizione predefinita del `workspace` e delle altre directory dati. | `~/.picoclaw` |
-
-**Esempi:**
-
-```bash
-# Esegui picoclaw usando un file di configurazione specifico
-# Il percorso del workspace verrà letto da quel file di configurazione
-PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
-
-# Esegui picoclaw con tutti i dati salvati in /opt/picoclaw
-# La configurazione verrà caricata dal percorso predefinito ~/.picoclaw/config.json
-# Il workspace verrà creato in /opt/picoclaw/workspace
-PICOCLAW_HOME=/opt/picoclaw picoclaw agent
-
-# Usa entrambi per un setup completamente personalizzato
-PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
-```
-
-### Struttura del Workspace
-
-PicoClaw salva i dati nel workspace configurato (predefinito: `~/.picoclaw/workspace`):
-
-```
-~/.picoclaw/workspace/
-├── sessions/ # Sessioni di conversazione e cronologia
-├── memory/ # Memoria a lungo termine (MEMORY.md)
-├── state/ # Stato persistente (ultimo canale, ecc.)
-├── cron/ # Database dei job pianificati
-├── skills/ # Skill personalizzate
-├── AGENTS.md # Guida al comportamento dell'agent
-├── HEARTBEAT.md # Prompt per task periodici (controllato ogni 30 min)
-├── IDENTITY.md # Identità dell'agent
-├── SOUL.md # Anima dell'agent
-└── USER.md # Preferenze dell'utente
-```
-
-> **Nota:** Le modifiche a `AGENTS.md`, `SOUL.md`, `USER.md`, `IDENTITY.md` e `memory/MEMORY.md` vengono rilevate automaticamente a runtime tramite il tracciamento della data di modifica (mtime). **Non è necessario riavviare il gateway** dopo aver modificato questi file — l'agent caricherà il nuovo contenuto alla prossima richiesta.
-
-### Sorgenti delle Skill
-
-Per impostazione predefinita, le skill vengono caricate da:
-
-1. `~/.picoclaw/workspace/skills` (workspace)
-2. `~/.picoclaw/skills` (globale)
-3. `/skills` (builtin)
-
-Per configurazioni avanzate/di test, puoi sovrascrivere la directory radice delle skill builtin con:
-
-```bash
-export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
-```
-
-### Politica Unificata di Esecuzione dei Comandi
-
-- I comandi slash generici vengono eseguiti tramite un unico percorso in `pkg/agent/loop.go` via `commands.Executor`.
-- Gli adattatori dei canali non consumano più localmente i comandi generici; inoltrano il testo in entrata al percorso bus/agent. Telegram registra ancora automaticamente i comandi supportati all'avvio.
-- Un comando slash sconosciuto (ad esempio `/foo`) viene passato all'elaborazione LLM come se fosse un messaggio dell'utente.
-- Un comando registrato ma non supportato sul canale corrente (ad esempio `/show` su WhatsApp) restituisce un errore esplicito all'utente e interrompe l'elaborazione.
-
-### 🔒 Sandbox di Sicurezza
-
-PicoClaw esegue in un ambiente sandboxed per impostazione predefinita. L'agent può accedere solo ai file ed eseguire comandi all'interno del workspace configurato.
-
-#### Configurazione Predefinita
-
-```json
-{
- "agents": {
- "defaults": {
- "workspace": "~/.picoclaw/workspace",
- "restrict_to_workspace": true
- }
- }
-}
-```
-
-| Opzione | Predefinito | Descrizione |
-| ----------------------- | ----------------------- | ---------------------------------------------------- |
-| `workspace` | `~/.picoclaw/workspace` | Directory di lavoro dell'agent |
-| `restrict_to_workspace` | `true` | Limita l'accesso a file/comandi al workspace |
-
-#### Strumenti Protetti
-
-Quando `restrict_to_workspace: true`, i seguenti strumenti sono in sandbox:
-
-| Strumento | Funzione | Restrizione |
-| ------------- | ------------------------- | ---------------------------------------------------- |
-| `read_file` | Legge file | Solo file all'interno del workspace |
-| `write_file` | Scrive file | Solo file all'interno del workspace |
-| `list_dir` | Elenca directory | Solo directory all'interno del workspace |
-| `edit_file` | Modifica file | Solo file all'interno del workspace |
-| `append_file` | Aggiunge ai file | Solo file all'interno del workspace |
-| `exec` | Esegue comandi | I percorsi dei comandi devono essere nel workspace |
-
-#### Protezione Exec Aggiuntiva
-
-Anche con `restrict_to_workspace: false`, lo strumento `exec` blocca questi comandi pericolosi:
-
-* `rm -rf`, `del /f`, `rmdir /s` — Cancellazione di massa
-* `format`, `mkfs`, `diskpart` — Formattazione del disco
-* `dd if=` — Imaging del disco
-* Scrittura su `/dev/sd[a-z]` — Scritture dirette su disco
-* `shutdown`, `reboot`, `poweroff` — Spegnimento del sistema
-* Fork bomb `:(){ :|:& };:`
-
-### Controllo Accesso ai File
-
-| Chiave di configurazione | Tipo | Predefinito | Descrizione |
-|--------------------------|------|-------------|-------------|
-| `tools.allow_read_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la lettura al di fuori del workspace |
-| `tools.allow_write_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la scrittura al di fuori del workspace |
-
-### Sicurezza Exec
-
-| Chiave di configurazione | Tipo | Predefinito | Descrizione |
-|--------------------------|------|-------------|-------------|
-| `tools.exec.allow_remote` | bool | `false` | Consente lo strumento exec da canali remoti (Telegram/Discord ecc.) |
-| `tools.exec.enable_deny_patterns` | bool | `true` | Abilita l'intercettazione dei comandi pericolosi |
-| `tools.exec.custom_deny_patterns` | string[] | `[]` | Pattern regex personalizzati da bloccare |
-| `tools.exec.custom_allow_patterns` | string[] | `[]` | Pattern regex personalizzati da consentire |
-
-> **Nota di sicurezza:** La protezione dei symlink è abilitata per impostazione predefinita — tutti i percorsi file vengono risolti tramite `filepath.EvalSymlinks` prima del confronto con la whitelist, prevenendo attacchi di escape tramite symlink.
-
-#### Limitazione Nota: Processi Figlio degli Strumenti di Build
-
-Il controllo di sicurezza exec ispeziona solo la riga di comando avviata direttamente da PicoClaw. Non ispeziona ricorsivamente i processi figlio generati da strumenti di sviluppo consentiti come `make`, `go run`, `cargo`, `npm run` o script di build personalizzati.
-
-Ciò significa che un comando di primo livello può comunque compilare o avviare altri binari dopo aver superato il controllo iniziale. In pratica, tratta gli script di build, i Makefile, gli script di pacchetti e i binari generati come codice eseguibile che richiede lo stesso livello di revisione di un comando shell diretto.
-
-Per ambienti ad alto rischio:
-
-* Esamina gli script di build prima dell'esecuzione.
-* Preferisci l'approvazione/revisione manuale per i workflow di compilazione ed esecuzione.
-* Esegui PicoClaw in un container o VM se hai bisogno di un isolamento più forte di quello fornito dal controllo integrato.
-
-#### Esempi di Errore
-
-```
-[ERROR] tool: Tool execution failed
-{tool=exec, error=Command blocked by safety guard (path outside working dir)}
-```
-
-```
-[ERROR] tool: Tool execution failed
-{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
-```
-
-#### Disabilitare le Restrizioni (Rischio di Sicurezza)
-
-Se hai bisogno che l'agent acceda a percorsi al di fuori del workspace:
-
-**Metodo 1: File di configurazione**
-
-```json
-{
- "agents": {
- "defaults": {
- "restrict_to_workspace": false
- }
- }
-}
-```
-
-**Metodo 2: Variabile d'ambiente**
-
-```bash
-export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
-```
-
-> ⚠️ **Attenzione**: Disabilitare questa restrizione consente all'agent di accedere a qualsiasi percorso sul tuo sistema. Usare con cautela solo in ambienti controllati.
-
-#### Coerenza dei Confini di Sicurezza
-
-L'impostazione `restrict_to_workspace` si applica in modo coerente a tutti i percorsi di esecuzione:
-
-| Percorso di esecuzione | Confine di sicurezza |
-| ---------------------- | --------------------------------- |
-| Main Agent | `restrict_to_workspace` ✅ |
-| Subagent / Spawn | Eredita la stessa restrizione ✅ |
-| Heartbeat tasks | Eredita la stessa restrizione ✅ |
-
-Tutti i percorsi condividono la stessa restrizione del workspace — non è possibile aggirare il confine di sicurezza tramite subagent o task pianificati.
-
-### Heartbeat (Task Periodici)
-
-PicoClaw può eseguire task periodici automaticamente. Crea un file `HEARTBEAT.md` nel tuo workspace:
-
-```markdown
-# Periodic Tasks
-
-- Check my email for important messages
-- Review my calendar for upcoming events
-- Check the weather forecast
-```
-
-L'agent leggerà questo file ogni 30 minuti (configurabile) ed eseguirà tutti i task usando gli strumenti disponibili.
-
-#### Task Asincroni con Spawn
-
-Per task di lunga durata (ricerca web, chiamate API), usa lo strumento `spawn` per creare un **subagent**:
-
-```markdown
-# Periodic Tasks
-```
diff --git a/docs/ja/providers.md b/docs/ja/providers.md
index e29c113f3..878530966 100644
--- a/docs/ja/providers.md
+++ b/docs/ja/providers.md
@@ -99,6 +99,24 @@
}
```
+#### `model_list` エントリフィールド
+
+| フィールド | 型 | 必須 | 説明 |
+|-----------|------|------|------|
+| `model_name` | string | はい | agent 設定でこのモデルを参照するための一意の名前 |
+| `model` | string | はい | ベンダー/モデル識別子(例:`openai/gpt-5.4`、`azure/gpt-5.4`、`anthropic/claude-sonnet-4.6`) |
+| `api_keys` | string[] | はい* | 認証キー。複数キーでリクエストごとのローテーションが可能。ローカル provider(Ollama、LM Studio、VLLM)には不要 |
+| `api_base` | string | いいえ | デフォルトの API エンドポイント URL を上書き |
+| `proxy` | string | いいえ | このモデルエントリの HTTP プロキシ URL |
+| `user_agent` | string | いいえ | カスタム `User-Agent` リクエストヘッダー(OpenAI 互換、Anthropic、Azure provider で対応) |
+| `request_timeout` | int | いいえ | リクエストタイムアウト(秒)。デフォルト値は provider により異なる |
+| `max_tokens_field` | string | いいえ | リクエストボディの max tokens フィールド名を上書き(例:o1 モデルでは `max_completion_tokens`) |
+| `thinking_level` | string | いいえ | 拡張思考レベル:`off`、`low`、`medium`、`high`、`xhigh`、`adaptive` |
+| `extra_body` | object | いいえ | 各リクエストボディに注入する追加フィールド |
+| `rpm` | int | いいえ | 1 分あたりのリクエストレート制限 |
+| `fallbacks` | string[] | いいえ | 自動フェイルオーバーのフォールバックモデル名 |
+| `enabled` | bool | いいえ | このモデルエントリを有効にするかどうか(デフォルト:`true`) |
+
#### ベンダー別設定例
**OpenAI**
@@ -201,6 +219,7 @@ Anthropic API への直接アクセスや、Anthropic のネイティブメッ
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
"api_keys": ["sk-..."],
+ "user_agent": "MyApp/1.0",
"request_timeout": 300
}
```
diff --git a/docs/providers.md b/docs/providers.md
index b0dfa0bc8..9bb95446c 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -108,6 +108,24 @@ This design also enables **multi-agent support** with flexible provider selectio
}
```
+#### `model_list` Entry Fields
+
+| Field | Type | Required | Description |
+|-------|------|----------|-------------|
+| `model_name` | string | Yes | Unique name used to reference this model in agent config |
+| `model` | string | Yes | Vendor/model identifier (e.g., `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) |
+| `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) |
+| `api_base` | string | No | Override the default API endpoint URL |
+| `proxy` | string | No | HTTP proxy URL for this model entry |
+| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Anthropic, and Azure providers) |
+| `request_timeout` | int | No | Request timeout in seconds (default varies by provider) |
+| `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) |
+| `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` |
+| `extra_body` | object | No | Additional fields to inject into every request body |
+| `rpm` | int | No | Per-minute request rate limit |
+| `fallbacks` | string[] | No | Fallback model names for automatic failover |
+| `enabled` | bool | No | Whether this model entry is active (default: `true`) |
+
#### Voice Transcription
You can configure a dedicated model for audio transcription with `voice.model_name`. This lets you reuse existing multimodal providers that support audio input instead of relying only on Groq.
@@ -249,6 +267,7 @@ PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
"api_keys": ["sk-..."],
+ "user_agent": "MyApp/1.0",
"request_timeout": 300
}
```
diff --git a/docs/pt-br/providers.md b/docs/pt-br/providers.md
index c7c6305e2..103490dc7 100644
--- a/docs/pt-br/providers.md
+++ b/docs/pt-br/providers.md
@@ -99,6 +99,24 @@ Este design também permite **suporte multi-agente** com seleção flexível de
}
```
+#### Campos de entrada `model_list`
+
+| Campo | Tipo | Obrigatório | Descrição |
+|-------|------|-------------|-----------|
+| `model_name` | string | Sim | Nome único para referenciar este modelo na config do agent |
+| `model` | string | Sim | Identificador fornecedor/modelo (ex: `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) |
+| `api_keys` | string[] | Sim* | Chave(s) API para autenticação. Múltiplas chaves permitem rotação por requisição. Não necessário para providers locais (Ollama, LM Studio, VLLM) |
+| `api_base` | string | Não | Substitui a URL base da API padrão |
+| `proxy` | string | Não | URL do proxy HTTP para esta entrada de modelo |
+| `user_agent` | string | Não | Cabeçalho `User-Agent` personalizado enviado com requisições API (suportado por providers OpenAI-compatible, Anthropic e Azure) |
+| `request_timeout` | int | Não | Timeout de requisição em segundos (o padrão varia por provider) |
+| `max_tokens_field` | string | Não | Substitui o nome do campo max tokens no corpo da requisição (ex: `max_completion_tokens` para modelos o1) |
+| `thinking_level` | string | Não | Nível de pensamento estendido: `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` |
+| `extra_body` | object | Não | Campos adicionais para injetar em cada corpo de requisição |
+| `rpm` | int | Não | Limite de requisições por minuto |
+| `fallbacks` | string[] | Não | Nomes dos modelos de fallback para failover automático |
+| `enabled` | bool | Não | Ativar ou desativar esta entrada de modelo (padrão: `true`) |
+
#### Exemplos por Vendor
**OpenAI**
@@ -190,6 +208,7 @@ Para acesso direto à API Anthropic ou endpoints personalizados que suportam ape
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
"api_keys": ["sk-..."],
+ "user_agent": "MyApp/1.0",
"request_timeout": 300
}
```
diff --git a/docs/vi/providers.md b/docs/vi/providers.md
index ffd992645..46c9de663 100644
--- a/docs/vi/providers.md
+++ b/docs/vi/providers.md
@@ -99,6 +99,24 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
}
```
+#### Các trường entry `model_list`
+
+| Trường | Kiểu | Bắt buộc | Mô tả |
+|--------|------|----------|------|
+| `model_name` | string | Có | Tên duy nhất để tham chiếu model này trong cấu hình agent |
+| `model` | string | Có | Định danh nhà cung cấp/model (ví dụ: `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) |
+| `api_keys` | string[] | Có* | Khóa API xác thực. Nhiều khóa cho phép xoay vòng theo yêu cầu. Không cần thiết cho provider nội bộ (Ollama, LM Studio, VLLM) |
+| `api_base` | string | Không | Ghi đè URL endpoint API mặc định |
+| `proxy` | string | Không | URL proxy HTTP cho entry model này |
+| `user_agent` | string | Không | Header `User-Agent` tùy chỉnh gửi với yêu cầu API (được hỗ trợ bởi provider OpenAI-compatible, Anthropic và Azure) |
+| `request_timeout` | int | Không | Timeout yêu cầu tính bằng giây (mặc định khác nhau tùy provider) |
+| `max_tokens_field` | string | Không | Ghi đè tên trường max tokens trong request body (ví dụ: `max_completion_tokens` cho model o1) |
+| `thinking_level` | string | Không | Mức độ tư duy mở rộng: `off`, `low`, `medium`, `high`, `xhigh` hoặc `adaptive` |
+| `extra_body` | object | Không | Các trường bổ sung để chèn vào mỗi request body |
+| `rpm` | int | Không | Giới hạn tốc độ yêu cầu mỗi phút |
+| `fallbacks` | string[] | Không | Tên model dự phòng cho failover tự động |
+| `enabled` | bool | Không | Kích hoạt hay vô hiệu hóa entry model này (mặc định: `true`) |
+
#### Ví Dụ Theo Vendor
**OpenAI**
@@ -190,6 +208,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
"api_keys": ["sk-..."],
+ "user_agent": "MyApp/1.0",
"request_timeout": 300
}
```
diff --git a/docs/zh/providers.md b/docs/zh/providers.md
index 43c4f26db..6048b929f 100644
--- a/docs/zh/providers.md
+++ b/docs/zh/providers.md
@@ -104,6 +104,24 @@
}
```
+#### `model_list` 条目字段
+
+| 字段 | 类型 | 必填 | 说明 |
+|------|------|------|------|
+| `model_name` | string | 是 | 在 agent 配置中引用此模型的唯一名称 |
+| `model` | string | 是 | 厂商/模型标识符(如 `openai/gpt-5.4`、`azure/gpt-5.4`、`anthropic/claude-sonnet-4.6`) |
+| `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 provider(Ollama、LM Studio、VLLM)不需要 |
+| `api_base` | string | 否 | 覆盖默认的 API 端点 URL |
+| `proxy` | string | 否 | 此模型条目的 HTTP 代理 URL |
+| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Anthropic 和 Azure provider) |
+| `request_timeout` | int | 否 | 请求超时时间(秒),默认值因 provider 而异 |
+| `max_tokens_field` | string | 否 | 覆盖请求体中 max tokens 的字段名(如 o1 模型使用 `max_completion_tokens`) |
+| `thinking_level` | string | 否 | 扩展思考级别:`off`、`low`、`medium`、`high`、`xhigh` 或 `adaptive` |
+| `extra_body` | object | 否 | 注入到每个请求体中的额外字段 |
+| `rpm` | int | 否 | 每分钟请求速率限制 |
+| `fallbacks` | string[] | 否 | 自动故障转移的备用模型名称 |
+| `enabled` | bool | 否 | 是否启用此模型条目(默认:`true`) |
+
#### 语音转录
你可以通过 `voice.model_name` 为语音转录指定一个专用模型。这样可以直接复用已经配置好的、支持音频输入的多模态 provider,而不必只依赖 Groq。
@@ -234,6 +252,7 @@ PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
"api_keys": ["sk-..."],
+ "user_agent": "MyApp/1.0",
"request_timeout": 300
}
```
diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go
index 562692e97..48e5aa625 100644
--- a/pkg/agent/instance.go
+++ b/pkg/agent/instance.go
@@ -81,7 +81,12 @@ func NewAgentInstance(
if cfg.Tools.IsToolEnabled("read_file") {
maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize
- toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
+ switch cfg.Tools.ReadFile.EffectiveMode() {
+ case config.ReadFileModeLines:
+ toolsRegistry.Register(tools.NewReadFileLinesTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
+ default:
+ toolsRegistry.Register(tools.NewReadFileBytesTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
+ }
}
if cfg.Tools.IsToolEnabled("write_file") {
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go
index f03c66c5e..c96766fa6 100644
--- a/pkg/agent/instance_test.go
+++ b/pkg/agent/instance_test.go
@@ -442,6 +442,47 @@ func TestNewAgentInstance_CandidateProvidersPopulatedForCrossProviderFallbacks(t
}
}
+func TestNewAgentInstance_ReadFileModeSelectsSchema(t *testing.T) {
+ workspace := t.TempDir()
+
+ cfg := &config.Config{
+ Agents: config.AgentsConfig{
+ Defaults: config.AgentDefaults{
+ Workspace: workspace,
+ ModelName: "test-model",
+ },
+ },
+ Tools: config.ToolsConfig{
+ ReadFile: config.ReadFileToolConfig{
+ Enabled: true,
+ Mode: config.ReadFileModeLines,
+ MaxReadFileSize: 4096,
+ },
+ },
+ }
+
+ agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{})
+ readTool, ok := agent.Tools.Get("read_file")
+ if !ok {
+ t.Fatal("read_file tool not registered")
+ }
+
+ params := readTool.Parameters()
+ props, _ := params["properties"].(map[string]any)
+ if _, ok := props["start_line"]; !ok {
+ t.Fatalf("expected line-mode schema to expose start_line, got %#v", props)
+ }
+ if _, ok := props["max_lines"]; !ok {
+ t.Fatalf("expected line-mode schema to expose max_lines, got %#v", props)
+ }
+ if _, ok := props["offset"]; ok {
+ t.Fatalf("did not expect line-mode schema to expose offset, got %#v", props)
+ }
+ if _, ok := props["length"]; ok {
+ t.Fatalf("did not expect line-mode schema to expose length, got %#v", props)
+ }
+}
+
func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) {
workspace := t.TempDir()
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 5fbf35ebf..239448a1c 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -12,6 +12,7 @@ import (
"fmt"
"math"
"net/http"
+ "sort"
"sync"
"time"
@@ -513,6 +514,8 @@ func (m *Manager) StartAll(ctx context.Context) error {
dispatchCtx, cancel := context.WithCancel(ctx)
m.dispatchTask = &asyncTask{cancel: cancel}
+ failedStarts := make([]error, 0, len(m.channels))
+ failedNames := make([]string, 0, len(m.channels))
for name, channel := range m.channels {
logger.InfoCF("channels", "Starting channel", map[string]any{
@@ -523,6 +526,8 @@ func (m *Manager) StartAll(ctx context.Context) error {
"channel": name,
"error": err.Error(),
})
+ failedStarts = append(failedStarts, fmt.Errorf("channel %s: %w", name, err))
+ failedNames = append(failedNames, name)
continue
}
// Lazily create worker only after channel starts successfully
@@ -532,6 +537,36 @@ func (m *Manager) StartAll(ctx context.Context) error {
go m.runMediaWorker(dispatchCtx, name, w)
}
+ if len(m.channels) > 0 && len(m.workers) == 0 {
+ if m.dispatchTask != nil {
+ m.dispatchTask.cancel()
+ m.dispatchTask = nil
+ }
+
+ sort.Strings(failedNames)
+ if len(failedStarts) == 0 {
+ return fmt.Errorf("failed to start any enabled channels")
+ }
+
+ logger.ErrorCF("channels", "All enabled channels failed to start", map[string]any{
+ "failed": len(failedNames),
+ "total": len(m.channels),
+ "failed_channels": failedNames,
+ })
+
+ return fmt.Errorf("failed to start any enabled channels: %w", errors.Join(failedStarts...))
+ }
+
+ if len(failedNames) > 0 {
+ sort.Strings(failedNames)
+ logger.WarnCF("channels", "Some channels failed to start", map[string]any{
+ "failed": len(failedNames),
+ "started": len(m.workers),
+ "total": len(m.channels),
+ "failed_channels": failedNames,
+ })
+ }
+
// Start the dispatcher that reads from the bus and routes to workers
go m.dispatchOutbound(dispatchCtx)
go m.dispatchOutboundMedia(dispatchCtx)
@@ -553,7 +588,11 @@ func (m *Manager) StartAll(ctx context.Context) error {
}()
}
- logger.InfoC("channels", "All channels started")
+ logger.InfoCF("channels", "Channel startup completed", map[string]any{
+ "started": len(m.workers),
+ "failed": len(failedNames),
+ "total": len(m.channels),
+ })
return nil
}
diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go
index e76212905..937b32d2c 100644
--- a/pkg/channels/manager_test.go
+++ b/pkg/channels/manager_test.go
@@ -19,6 +19,8 @@ import (
type mockChannel struct {
BaseChannel
sendFn func(ctx context.Context, msg bus.OutboundMessage) error
+ startFn func(ctx context.Context) error
+ stopFn func(ctx context.Context) error
sentMessages []bus.OutboundMessage
placeholdersSent int
editedMessages int
@@ -33,8 +35,19 @@ func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
return nil, m.sendFn(ctx, msg)
}
-func (m *mockChannel) Start(ctx context.Context) error { return nil }
-func (m *mockChannel) Stop(ctx context.Context) error { return nil }
+func (m *mockChannel) Start(ctx context.Context) error {
+ if m.startFn != nil {
+ return m.startFn(ctx)
+ }
+ return nil
+}
+
+func (m *mockChannel) Stop(ctx context.Context) error {
+ if m.stopFn != nil {
+ return m.stopFn(ctx)
+ }
+ return nil
+}
func (m *mockChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
m.placeholdersSent++
@@ -86,6 +99,101 @@ func newTestManager() *Manager {
return &Manager{
channels: make(map[string]Channel),
workers: make(map[string]*channelWorker),
+ bus: bus.NewMessageBus(),
+ }
+}
+
+func TestStartAll_AllChannelsFail_ReturnsJoinedError(t *testing.T) {
+ m := newTestManager()
+ errA := errors.New("channel-a start failed")
+ errB := errors.New("channel-b start failed")
+
+ m.channels["a"] = &mockChannel{
+ startFn: func(_ context.Context) error { return errA },
+ }
+ m.channels["b"] = &mockChannel{
+ startFn: func(_ context.Context) error { return errB },
+ }
+
+ err := m.StartAll(t.Context())
+ if err == nil {
+ t.Fatal("expected StartAll to fail when all channels fail")
+ }
+ if !strings.Contains(err.Error(), "failed to start any enabled channels") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !errors.Is(err, errA) {
+ t.Fatalf("expected error to wrap errA, got: %v", err)
+ }
+ if !errors.Is(err, errB) {
+ t.Fatalf("expected error to wrap errB, got: %v", err)
+ }
+ if len(m.workers) != 0 {
+ t.Fatalf("expected no workers on full startup failure, got %d", len(m.workers))
+ }
+ if m.dispatchTask != nil {
+ t.Fatal("expected dispatch task to be cleared on full startup failure")
+ }
+}
+
+func TestStartAll_PartialFailure_StartsSuccessfulWorkers(t *testing.T) {
+ m := newTestManager()
+ errBad := errors.New("bad channel start failed")
+ processed := make(chan struct{}, 1)
+
+ m.channels["good"] = &mockChannel{
+ sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
+ if msg.Channel == "good" {
+ select {
+ case processed <- struct{}{}:
+ default:
+ }
+ }
+ return nil
+ },
+ }
+ m.channels["bad"] = &mockChannel{
+ startFn: func(_ context.Context) error { return errBad },
+ }
+
+ err := m.StartAll(t.Context())
+ if err != nil {
+ t.Fatalf("expected StartAll to succeed with partial channel failures, got: %v", err)
+ }
+ if len(m.workers) != 1 {
+ t.Fatalf("expected exactly 1 active worker, got %d", len(m.workers))
+ }
+ if _, ok := m.workers["good"]; !ok {
+ t.Fatal("expected worker for successful channel 'good'")
+ }
+ if _, ok := m.workers["bad"]; ok {
+ t.Fatal("did not expect worker for failed channel 'bad'")
+ }
+ if m.dispatchTask == nil {
+ t.Fatal("expected dispatch task to run when at least one channel starts")
+ }
+
+ pubCtx, pubCancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer pubCancel()
+ if err := m.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
+ Channel: "good",
+ ChatID: "chat-1",
+ Content: "hello",
+ }); err != nil {
+ t.Fatalf("PublishOutbound() error = %v", err)
+ }
+
+ select {
+ case <-processed:
+ // worker processed outbound message as expected
+ case <-time.After(2 * time.Second):
+ t.Fatal("expected successful channel worker to process outbound message")
+ }
+
+ stopCtx, stopCancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer stopCancel()
+ if err := m.StopAll(stopCtx); err != nil {
+ t.Fatalf("StopAll() error = %v", err)
}
}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index a35689bc1..4e8733cbf 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -7,6 +7,7 @@ import (
"math/rand"
"os"
"path/filepath"
+ "strings"
"sync/atomic"
"time"
@@ -600,6 +601,8 @@ type ModelConfig struct {
// existing configs, the field is inferred during load: models with API keys
// or the reserved "local-model" name are auto-enabled.
Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"`
+ // UserAgent is the user agent string to use for HTTP requests.
+ UserAgent string `json:"user_agent,omitempty" yaml:"-"`
// isVirtual marks this model as a virtual model generated from multi-key expansion.
// Virtual models should not be persisted to config files.
@@ -801,8 +804,25 @@ type MediaCleanupConfig struct {
}
type ReadFileToolConfig struct {
- Enabled bool `json:"enabled"`
- MaxReadFileSize int `json:"max_read_file_size"`
+ Enabled bool `json:"enabled"`
+ Mode string `json:"mode"`
+ MaxReadFileSize int `json:"max_read_file_size"`
+}
+
+const (
+ ReadFileModeBytes = "bytes"
+ ReadFileModeLines = "lines"
+)
+
+func (c ReadFileToolConfig) EffectiveMode() string {
+ switch strings.ToLower(strings.TrimSpace(c.Mode)) {
+ case ReadFileModeLines:
+ return ReadFileModeLines
+ case "", ReadFileModeBytes:
+ return ReadFileModeBytes
+ default:
+ return ReadFileModeBytes
+ }
}
type ToolsConfig struct {
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 278dfa43a..a1410f940 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -317,6 +317,13 @@ func TestDefaultConfig_WebTools(t *testing.T) {
}
}
+func TestDefaultConfig_ReadFileMode(t *testing.T) {
+ cfg := DefaultConfig()
+ if cfg.Tools.ReadFile.EffectiveMode() != ReadFileModeBytes {
+ t.Fatalf("expected default read_file mode %q, got %q", ReadFileModeBytes, cfg.Tools.ReadFile.EffectiveMode())
+ }
+}
+
func TestSaveConfig_FilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("file permission bits are not enforced on Windows")
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index a9a107975..39cdb89e6 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -487,6 +487,7 @@ func DefaultConfig() *Config {
},
ReadFile: ReadFileToolConfig{
Enabled: true,
+ Mode: ReadFileModeBytes,
MaxReadFileSize: 64 * 1024, // 64KB
},
Spawn: ToolConfig{
diff --git a/pkg/providers/anthropic_messages/provider.go b/pkg/providers/anthropic_messages/provider.go
index 6a1c473dd..1e865b709 100644
--- a/pkg/providers/anthropic_messages/provider.go
+++ b/pkg/providers/anthropic_messages/provider.go
@@ -41,15 +41,16 @@ type Provider struct {
apiKey string
apiBase string
httpClient *http.Client
+ userAgent string
}
// NewProvider creates a new Anthropic Messages API provider.
-func NewProvider(apiKey, apiBase string) *Provider {
- return NewProviderWithTimeout(apiKey, apiBase, 0)
+func NewProvider(apiKey, apiBase, userAgent string) *Provider {
+ return NewProviderWithTimeout(apiKey, apiBase, userAgent, 0)
}
// NewProviderWithTimeout creates a provider with custom request timeout.
-func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provider {
+func NewProviderWithTimeout(apiKey, apiBase, userAgent string, timeoutSeconds int) *Provider {
baseURL := normalizeBaseURL(apiBase)
timeout := defaultRequestTimeout
if timeoutSeconds > 0 {
@@ -57,8 +58,9 @@ func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provide
}
return &Provider{
- apiKey: apiKey,
- apiBase: baseURL,
+ apiKey: apiKey,
+ apiBase: baseURL,
+ userAgent: userAgent,
httpClient: &http.Client{
Timeout: timeout,
},
@@ -105,6 +107,9 @@ func (p *Provider) Chat(
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", p.apiKey) //nolint:canonicalheader // Anthropic API requires exact header name
req.Header.Set("Anthropic-Version", defaultAPIVersion)
+ if p.userAgent != "" {
+ req.Header.Set("User-Agent", p.userAgent)
+ }
// Execute request
resp, err := p.httpClient.Do(req)
diff --git a/pkg/providers/anthropic_messages/provider_test.go b/pkg/providers/anthropic_messages/provider_test.go
index 39bc48117..ba9d24b66 100644
--- a/pkg/providers/anthropic_messages/provider_test.go
+++ b/pkg/providers/anthropic_messages/provider_test.go
@@ -411,7 +411,7 @@ func TestNormalizeBaseURL(t *testing.T) {
}
func TestNewProvider(t *testing.T) {
- provider := NewProvider("test-key", "https://api.example.com")
+ provider := NewProvider("test-key", "https://api.example.com", "")
if provider == nil {
t.Fatal("NewProvider() returned nil")
}
@@ -424,7 +424,7 @@ func TestNewProvider(t *testing.T) {
}
func TestGetDefaultModel(t *testing.T) {
- provider := NewProvider("test-key", "")
+ provider := NewProvider("test-key", "", "")
got := provider.GetDefaultModel()
expected := "claude-sonnet-4.6"
if got != expected {
@@ -743,7 +743,7 @@ func TestProviderChatErrors(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create provider using constructor to ensure proper initialization
- provider := NewProvider(tt.apiKey, "https://api.example.com")
+ provider := NewProvider(tt.apiKey, "https://api.example.com", "")
_, err := provider.Chat(context.Background(), tt.messages, nil, "test-model", nil)
if err == nil {
diff --git a/pkg/providers/azure/provider.go b/pkg/providers/azure/provider.go
index 429b26798..7de703248 100644
--- a/pkg/providers/azure/provider.go
+++ b/pkg/providers/azure/provider.go
@@ -36,6 +36,7 @@ type Provider struct {
apiKey string
apiBase string
httpClient *http.Client
+ userAgent string
}
// Option configures the Azure Provider.
@@ -50,11 +51,19 @@ func WithRequestTimeout(timeout time.Duration) Option {
}
}
+// WithUserAgent sets the User-Agent header for requests.
+func WithUserAgent(userAgent string) Option {
+ return func(p *Provider) {
+ p.userAgent = userAgent
+ }
+}
+
// NewProvider creates a new Azure OpenAI provider.
-func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
+func NewProvider(apiKey, apiBase, proxy, userAgent string, opts ...Option) *Provider {
p := &Provider{
apiKey: apiKey,
apiBase: strings.TrimRight(apiBase, "/"),
+ userAgent: userAgent,
httpClient: common.NewHTTPClient(proxy),
}
@@ -68,9 +77,9 @@ func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
}
// NewProviderWithTimeout creates a new Azure OpenAI provider with a custom request timeout in seconds.
-func NewProviderWithTimeout(apiKey, apiBase, proxy string, requestTimeoutSeconds int) *Provider {
+func NewProviderWithTimeout(apiKey, apiBase, proxy, userAgent string, requestTimeoutSeconds int) *Provider {
return NewProvider(
- apiKey, apiBase, proxy,
+ apiKey, apiBase, proxy, userAgent,
WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
)
}
@@ -141,6 +150,9 @@ func (p *Provider) Chat(
if p.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+p.apiKey)
}
+ if p.userAgent != "" {
+ req.Header.Set("User-Agent", p.userAgent)
+ }
resp, err := p.httpClient.Do(req)
if err != nil {
diff --git a/pkg/providers/azure/provider_test.go b/pkg/providers/azure/provider_test.go
index b3752ea50..816ae97dc 100644
--- a/pkg/providers/azure/provider_test.go
+++ b/pkg/providers/azure/provider_test.go
@@ -46,7 +46,7 @@ func TestProviderChat_AzureURLConstruction(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-gpt5-deployment", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
@@ -69,7 +69,7 @@ func TestProviderChat_AzureAuthHeader(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("test-azure-key", server.URL, "")
+ p := NewProvider("test-azure-key", server.URL, "", "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
@@ -92,7 +92,7 @@ func TestProviderChat_AzureRequestBodyContainsModel(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-deployment", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
@@ -112,7 +112,7 @@ func TestProviderChat_AzureUsesMaxOutputTokens(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
_, err := p.Chat(
t.Context(),
[]Message{{Role: "user", Content: "hi"}},
@@ -144,7 +144,7 @@ func TestProviderChat_AzureStoreIsFalse(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
@@ -161,7 +161,7 @@ func TestProviderChat_AzureHTTPError(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("bad-key", server.URL, "")
+ p := NewProvider("bad-key", server.URL, "", "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
if err == nil {
t.Fatal("expected error, got nil")
@@ -176,7 +176,7 @@ func TestProviderChat_AzureRateLimitError(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
if err == nil {
t.Fatal("expected error for 429, got nil")
@@ -194,7 +194,7 @@ func TestProviderChat_AzureServerError(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
if err == nil {
t.Fatal("expected error for 500, got nil")
@@ -229,7 +229,7 @@ func TestProviderChat_AzureParseTextOutput(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
@@ -270,7 +270,7 @@ func TestProviderChat_AzureParseToolCalls(t *testing.T) {
}))
defer server.Close()
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "weather?"}}, nil, "deployment", nil)
if err != nil {
t.Fatalf("Chat() error = %v", err)
@@ -287,7 +287,7 @@ func TestProviderChat_AzureParseToolCalls(t *testing.T) {
}
func TestProvider_AzureEmptyAPIBase(t *testing.T) {
- p := NewProvider("test-key", "", "")
+ p := NewProvider("test-key", "", "", "")
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
if err == nil {
t.Fatal("expected error for empty API base")
@@ -295,21 +295,21 @@ func TestProvider_AzureEmptyAPIBase(t *testing.T) {
}
func TestProvider_AzureRequestTimeoutDefault(t *testing.T) {
- p := NewProvider("test-key", "https://example.com", "")
+ p := NewProvider("test-key", "https://example.com", "", "")
if p.httpClient.Timeout != defaultRequestTimeout {
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
}
}
func TestProvider_AzureRequestTimeoutOverride(t *testing.T) {
- p := NewProvider("test-key", "https://example.com", "", WithRequestTimeout(300*time.Second))
+ p := NewProvider("test-key", "https://example.com", "", "", WithRequestTimeout(300*time.Second))
if p.httpClient.Timeout != 300*time.Second {
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second)
}
}
func TestProvider_AzureNewProviderWithTimeout(t *testing.T) {
- p := NewProviderWithTimeout("test-key", "https://example.com", "", 180)
+ p := NewProviderWithTimeout("test-key", "https://example.com", "", "", 180)
if p.httpClient.Timeout != 180*time.Second {
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 180*time.Second)
}
@@ -343,7 +343,7 @@ func TestProviderChat_AzureNativeWebSearchInjection(t *testing.T) {
},
}
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
// With native_search=true: user-defined web_search should be replaced by built-in
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment",
@@ -393,7 +393,7 @@ func TestProviderChat_AzureNoNativeWebSearch(t *testing.T) {
},
}
- p := NewProvider("test-key", server.URL, "")
+ p := NewProvider("test-key", server.URL, "", "")
// Without native_search: user-defined web_search should be kept as-is
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment", nil)
diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go
index fb5191bf8..ab7277fae 100644
--- a/pkg/providers/factory_provider.go
+++ b/pkg/providers/factory_provider.go
@@ -129,6 +129,11 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
protocol, modelID := ExtractProtocol(cfg.Model)
+ userAgent := cfg.UserAgent
+ if userAgent == "" {
+ userAgent = fmt.Sprintf("PicoClaw/%s", config.Version)
+ }
+
switch protocol {
case "openai":
// OpenAI with OAuth/token auth (Codex-style)
@@ -152,6 +157,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
apiBase,
cfg.Proxy,
cfg.MaxTokensField,
+ userAgent,
cfg.RequestTimeout,
cfg.ExtraBody,
), modelID, nil
@@ -171,6 +177,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
cfg.APIKey(),
cfg.APIBase,
cfg.Proxy,
+ userAgent,
cfg.RequestTimeout,
), modelID, nil
@@ -228,6 +235,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
apiBase,
cfg.Proxy,
cfg.MaxTokensField,
+ userAgent,
cfg.RequestTimeout,
cfg.ExtraBody,
), modelID, nil
@@ -253,6 +261,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
apiBase,
cfg.Proxy,
cfg.MaxTokensField,
+ userAgent,
cfg.RequestTimeout,
extraBody,
), modelID, nil
@@ -279,6 +288,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
apiBase,
cfg.Proxy,
cfg.MaxTokensField,
+ userAgent,
cfg.RequestTimeout,
cfg.ExtraBody,
), modelID, nil
@@ -295,6 +305,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
return anthropicmessages.NewProviderWithTimeout(
cfg.APIKey(),
apiBase,
+ userAgent,
cfg.RequestTimeout,
), modelID, nil
@@ -310,6 +321,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
return anthropicmessages.NewProviderWithTimeout(
cfg.APIKey(),
apiBase,
+ userAgent,
cfg.RequestTimeout,
), modelID, nil
diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go
index e2eafb934..b4f672f7a 100644
--- a/pkg/providers/factory_provider_test.go
+++ b/pkg/providers/factory_provider_test.go
@@ -846,6 +846,107 @@ func TestCreateProviderFromConfig_MinimaxPreservesUserExtraBody(t *testing.T) {
}
}
+// openaiCompatResponse is the JSON response used by OpenAI-compatible providers.
+const openaiCompatResponse = `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`
+
+// anthropicResponse is the JSON response used by Anthropic providers.
+const anthropicResponse = `{"content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","model":"claude-sonnet-4-20250514","usage":{"input_tokens":10,"output_tokens":5}}`
+
+func TestCreateProviderFromConfig_UserAgent(t *testing.T) {
+ defaultUA := "PicoClaw/" + config.Version
+
+ tests := []struct {
+ name string
+ model string
+ userAgent string
+ apiKey string
+ response string
+ wantUA string
+ chatOpts map[string]any
+ }{
+ {
+ name: "openai default user agent",
+ model: "openai/gpt-4o",
+ apiKey: "test-key",
+ response: openaiCompatResponse,
+ wantUA: defaultUA,
+ },
+ {
+ name: "openai custom user agent",
+ model: "openai/gpt-4o",
+ apiKey: "test-key",
+ userAgent: "MyAgent/1.2.3",
+ response: openaiCompatResponse,
+ wantUA: "MyAgent/1.2.3",
+ },
+ {
+ name: "anthropic default user agent",
+ model: "anthropic/claude-sonnet-4-20250514",
+ apiKey: "test-key",
+ response: anthropicResponse,
+ wantUA: defaultUA,
+ },
+ {
+ name: "anthropic-messages default user agent",
+ model: "anthropic-messages/claude-sonnet-4-20250514",
+ apiKey: "test-key",
+ response: anthropicResponse,
+ wantUA: defaultUA,
+ chatOpts: map[string]any{"max_tokens": 1024},
+ },
+ {
+ name: "azure default user agent",
+ model: "azure/my-deployment",
+ apiKey: "test-azure-key",
+ response: openaiCompatResponse,
+ wantUA: defaultUA,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var receivedUA string
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ receivedUA = r.Header.Get("User-Agent")
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(tt.response))
+ }))
+ defer server.Close()
+
+ cfg := &config.ModelConfig{
+ ModelName: "test-ua-" + tt.name,
+ Model: tt.model,
+ APIBase: server.URL,
+ UserAgent: tt.userAgent,
+ }
+ cfg.SetAPIKey(tt.apiKey)
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if provider == nil {
+ t.Fatal("CreateProviderFromConfig() returned nil provider")
+ }
+
+ _, err = provider.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hi"}},
+ nil,
+ modelID,
+ tt.chatOpts,
+ )
+ if err != nil {
+ t.Fatalf("Chat() error = %v", err)
+ }
+
+ if receivedUA != tt.wantUA {
+ t.Errorf("User-Agent = %q, want %q", receivedUA, tt.wantUA)
+ }
+ })
+ }
+}
+
func TestCreateProviderFromConfig_Bedrock(t *testing.T) {
// Set dummy AWS env vars to make test deterministic
t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index f2ff52f1d..dae730536 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -24,11 +24,11 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
}
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
- return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0, nil)
+ return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, "", 0, nil)
}
func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
- apiKey, apiBase, proxy, maxTokensField string,
+ apiKey, apiBase, proxy, maxTokensField, userAgent string,
requestTimeoutSeconds int,
extraBody map[string]any,
) *HTTPProvider {
@@ -40,6 +40,7 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
openai_compat.WithMaxTokensField(maxTokensField),
openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
openai_compat.WithExtraBody(extraBody),
+ openai_compat.WithUserAgent(userAgent),
),
}
}
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index 4ff42506f..7cda033ad 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -36,6 +36,7 @@ type Provider struct {
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
httpClient *http.Client
extraBody map[string]any // Additional fields to inject into request body
+ userAgent string
}
type Option func(*Provider)
@@ -66,6 +67,12 @@ func WithMaxTokensField(maxTokensField string) Option {
}
}
+func WithUserAgent(userAgent string) Option {
+ return func(p *Provider) {
+ p.userAgent = userAgent
+ }
+}
+
func WithRequestTimeout(timeout time.Duration) Option {
return func(p *Provider) {
if timeout > 0 {
@@ -198,6 +205,9 @@ func (p *Provider) Chat(
}
req.Header.Set("Content-Type", "application/json")
+ if p.userAgent != "" {
+ req.Header.Set("User-Agent", p.userAgent)
+ }
if p.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+p.apiKey)
}
diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go
index 39d45013d..0b9a16950 100644
--- a/pkg/tools/filesystem.go
+++ b/pkg/tools/filesystem.go
@@ -1,18 +1,22 @@
package tools
import (
+ "bufio"
+ "bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"math"
+ "net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
+ "unicode/utf8"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -20,7 +24,11 @@ import (
const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow
-func validatePathWithAllowPaths(path, workspace string, restrict bool, patterns []*regexp.Regexp) (string, error) {
+func validatePathWithAllowPaths(
+ path, workspace string,
+ restrict bool,
+ patterns []*regexp.Regexp,
+) (string, error) {
if workspace == "" {
return path, fmt.Errorf("workspace is not defined")
}
@@ -253,6 +261,11 @@ type ReadFileTool struct {
maxSize int64
}
+type ReadFileLinesTool struct {
+ fs fileSystem
+ maxSize int64
+}
+
func NewReadFileTool(
workspace string,
restrict bool,
@@ -275,14 +288,53 @@ func NewReadFileTool(
}
}
+func NewReadFileBytesTool(
+ workspace string,
+ restrict bool,
+ maxReadFileSize int,
+ allowPaths ...[]*regexp.Regexp,
+) *ReadFileTool {
+ return NewReadFileTool(workspace, restrict, maxReadFileSize, allowPaths...)
+}
+
+func NewReadFileLinesTool(
+ workspace string,
+ restrict bool,
+ maxReadFileSize int,
+ allowPaths ...[]*regexp.Regexp,
+) *ReadFileLinesTool {
+ var patterns []*regexp.Regexp
+ if len(allowPaths) > 0 {
+ patterns = allowPaths[0]
+ }
+
+ maxSize := int64(maxReadFileSize)
+ if maxSize <= 0 {
+ maxSize = MaxReadFileSize
+ }
+
+ return &ReadFileLinesTool{
+ fs: buildFs(workspace, restrict, patterns),
+ maxSize: maxSize,
+ }
+}
+
func (t *ReadFileTool) Name() string {
return "read_file"
}
+func (t *ReadFileLinesTool) Name() string {
+ return "read_file"
+}
+
func (t *ReadFileTool) Description() string {
return "Read the contents of a file. Supports pagination via `offset` and `length`."
}
+func (t *ReadFileLinesTool) Description() string {
+ return "Read a UTF-8 text file from the filesystem. Output always includes line numbers in the format `LINE_NUMBER|LINE_CONTENT` (1-indexed). Supports partial reads via `start_line` and `max_lines` for large text files."
+}
+
func (t *ReadFileTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
@@ -306,6 +358,28 @@ func (t *ReadFileTool) Parameters() map[string]any {
}
}
+func (t *ReadFileLinesTool) Parameters() map[string]any {
+ return map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "path": map[string]any{
+ "type": "string",
+ "description": "Path to the file to read.",
+ },
+ "start_line": map[string]any{
+ "type": "integer",
+ "description": "Line number to start reading from (1-indexed, inclusive).",
+ "default": 1,
+ },
+ "max_lines": map[string]any{
+ "type": "integer",
+ "description": "Maximum number of lines to read.",
+ },
+ },
+ "required": []string{"path"},
+ }
+}
+
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
@@ -447,6 +521,302 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return NewToolResult(header + "\n\n" + string(data))
}
+func (t *ReadFileLinesTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
+ path, ok := args["path"].(string)
+ if !ok {
+ return ErrorResult("path is required")
+ }
+
+ startLine, err := getInt64Arg(args, "start_line", 1)
+ if err != nil {
+ return ErrorResult(err.Error())
+ }
+ if startLine < 1 {
+ return ErrorResult("start_line must be >= 1")
+ }
+ if _, exists := args["offset"]; exists {
+ return ErrorResult("offset is not supported in line mode; use start_line")
+ }
+ if _, exists := args["length"]; exists {
+ return ErrorResult("length is not supported in line mode; use max_lines")
+ }
+ if _, exists := args["limit"]; exists {
+ return ErrorResult("limit is not supported in line mode; use max_lines")
+ }
+
+ limit := int64(-1)
+ if raw, exists := args["max_lines"]; exists && raw != nil {
+ limit, err = getInt64Arg(args, "max_lines", -1)
+ if err != nil {
+ return ErrorResult(err.Error())
+ }
+ if limit <= 0 {
+ return ErrorResult("max_lines, if provided, must be > 0")
+ }
+ }
+
+ file, err := t.fs.Open(path)
+ if err != nil {
+ return ErrorResult(err.Error())
+ }
+ defer file.Close()
+
+ if info, statErr := file.Stat(); statErr == nil && info.IsDir() {
+ return ErrorResult(fmt.Sprintf("failed to open file: path is a directory: %s", path))
+ }
+
+ sample := make([]byte, 512)
+ sampleN, readErr := file.Read(sample)
+ if readErr != nil && readErr != io.EOF {
+ return ErrorResult(fmt.Sprintf("failed to read file: %v", readErr))
+ }
+ sample = sample[:sampleN]
+ if isBinaryReadFileData(sample) {
+ return ErrorResult("file appears to be binary; switch read_file mode to 'bytes' for byte-based inspection")
+ }
+
+ reader := bufio.NewReaderSize(io.MultiReader(bytes.NewReader(sample), file), 32*1024)
+
+ var content strings.Builder
+ lineIndex := int64(1)
+ var linesRead int64
+ var fileBytesRead int64
+ var outputBytesRead int64
+ var reachedEOF bool
+ var byteBudgetTruncated bool
+ var lineTruncated bool
+
+ for lineIndex < startLine {
+ hasLine, consumeErr := consumeNextLine(reader)
+ if consumeErr != nil {
+ return ErrorResult(fmt.Sprintf("failed to read file content: %v", consumeErr))
+ }
+ if !hasLine {
+ reachedEOF = true
+ break
+ }
+ lineIndex++
+ }
+
+ for !reachedEOF && (limit < 0 || linesRead < limit) {
+ prefix := formatReadFileLinePrefix(lineIndex)
+ remaining := t.maxSize - outputBytesRead - int64(len(prefix))
+ if remaining <= 0 {
+ byteBudgetTruncated = true
+ break
+ }
+
+ line, complete, hasLine, readLineErr := readNextLinePrefix(reader, remaining)
+ if readLineErr != nil {
+ return ErrorResult(fmt.Sprintf("failed to read file content: %v", readLineErr))
+ }
+ if !hasLine {
+ reachedEOF = true
+ break
+ }
+
+ content.WriteString(prefix)
+ content.Write(line)
+ fileBytesRead += int64(len(line))
+ outputBytesRead += int64(len(prefix) + len(line))
+ linesRead++
+ lineIndex++
+
+ if !complete {
+ byteBudgetTruncated = true
+ lineTruncated = true
+ break
+ }
+ }
+
+ if !reachedEOF && !lineTruncated {
+ hasMoreContent, peekErr := readerHasMoreContent(reader)
+ if peekErr != nil {
+ return ErrorResult(fmt.Sprintf("failed to inspect remaining file content: %v", peekErr))
+ }
+ if !hasMoreContent {
+ reachedEOF = true
+ byteBudgetTruncated = false
+ }
+ }
+
+ if linesRead == 0 && content.Len() == 0 {
+ return NewToolResult(fmt.Sprintf("[END OF FILE - no content at or after start_line=%d]", startLine))
+ }
+
+ start := startLine
+ endLine := startLine + linesRead - 1
+ displayPath := filepath.Base(path)
+ header := fmt.Sprintf(
+ "[file: %s | read: lines %d-%d (1-indexed) | file_bytes: %d | output_bytes: %d]",
+ displayPath, start, endLine, fileBytesRead, outputBytesRead,
+ )
+
+ switch {
+ case lineTruncated:
+ header += fmt.Sprintf(
+ "\n[TRUNCATED - line %d exceeded the %d byte read budget and was cut mid-line.]",
+ endLine,
+ t.maxSize,
+ )
+ case byteBudgetTruncated:
+ if limit > 0 {
+ header += fmt.Sprintf(
+ "\n[TRUNCATED - byte budget reached. Call read_file again with start_line=%d and max_lines=%d to continue at the next line.]",
+ startLine+linesRead,
+ limit,
+ )
+ } else {
+ header += fmt.Sprintf(
+ "\n[TRUNCATED - byte budget reached. Call read_file again with start_line=%d to continue at the next line.]",
+ startLine+linesRead,
+ )
+ }
+ case !reachedEOF && limit > 0 && linesRead >= limit:
+ header += fmt.Sprintf(
+ "\n[PARTIAL - more content remains. Call read_file again with start_line=%d and max_lines=%d to continue.]",
+ startLine+linesRead,
+ limit,
+ )
+ default:
+ header += "\n[END OF FILE - no further content.]"
+ }
+
+ logger.DebugCF("tool", "ReadFileTool execution completed successfully",
+ map[string]any{
+ "path": path,
+ "lines_read": linesRead,
+ "file_bytes_read": fileBytesRead,
+ "output_bytes_read": outputBytesRead,
+ "truncated": byteBudgetTruncated,
+ "tool": t.Name(),
+ })
+
+ return NewToolResult(header + "\n\n" + content.String())
+}
+
+func formatReadFileLinePrefix(lineNumber int64) string {
+ return strconv.FormatInt(lineNumber, 10) + "|"
+}
+
+func isBinaryReadFileData(data []byte) bool {
+ if len(data) == 0 {
+ return false
+ }
+
+ sample := data
+ if len(sample) > 512 {
+ sample = sample[:512]
+ }
+
+ if bytes.IndexByte(sample, 0) >= 0 {
+ return true
+ }
+
+ contentType := http.DetectContentType(sample)
+ if strings.HasPrefix(contentType, "text/") {
+ return false
+ }
+ if strings.HasSuffix(contentType, "/json") ||
+ strings.HasSuffix(contentType, "+json") ||
+ strings.HasSuffix(contentType, "/xml") ||
+ strings.HasSuffix(contentType, "+xml") ||
+ strings.Contains(contentType, "javascript") {
+ return false
+ }
+
+ if !utf8.Valid(sample) {
+ return true
+ }
+
+ controlChars := 0
+ for _, b := range sample {
+ if b < 0x20 && b != '\n' && b != '\r' && b != '\t' && b != '\f' && b != '\b' {
+ controlChars++
+ }
+ }
+
+ return float64(controlChars)/float64(len(sample)) > 0.1
+}
+
+func consumeNextLine(reader *bufio.Reader) (bool, error) {
+ sawData := false
+
+ for {
+ fragment, err := reader.ReadSlice('\n')
+ if len(fragment) > 0 {
+ sawData = true
+ }
+
+ switch {
+ case err == nil:
+ return true, nil
+ case errors.Is(err, bufio.ErrBufferFull):
+ continue
+ case errors.Is(err, io.EOF):
+ return sawData, nil
+ default:
+ return false, err
+ }
+ }
+}
+
+func readNextLinePrefix(reader *bufio.Reader, maxBytes int64) ([]byte, bool, bool, error) {
+ if maxBytes <= 0 {
+ return nil, false, false, nil
+ }
+
+ var out bytes.Buffer
+ sawData := false
+ complete := true
+
+ for {
+ fragment, err := reader.ReadSlice('\n')
+ if len(fragment) > 0 {
+ sawData = true
+ if remaining := maxBytes - int64(out.Len()); remaining > 0 {
+ take := len(fragment)
+ if int64(take) > remaining {
+ take = int(remaining)
+ complete = false
+ }
+ out.Write(fragment[:take])
+ } else {
+ complete = false
+ }
+ }
+
+ switch {
+ case err == nil:
+ return out.Bytes(), complete, sawData, nil
+ case errors.Is(err, bufio.ErrBufferFull):
+ if !complete {
+ return out.Bytes(), false, true, nil
+ }
+ continue
+ case errors.Is(err, io.EOF):
+ if !sawData {
+ return nil, true, false, nil
+ }
+ return out.Bytes(), complete, true, nil
+ default:
+ return nil, false, false, err
+ }
+ }
+}
+
+func readerHasMoreContent(reader *bufio.Reader) (bool, error) {
+ _, err := reader.Peek(1)
+ switch {
+ case err == nil:
+ return true, nil
+ case errors.Is(err, io.EOF):
+ return false, nil
+ default:
+ return false, err
+ }
+}
+
// getInt64Arg extracts an integer argument from the args map, returning the
// provided default if the key is absent.
func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) {
@@ -483,7 +853,11 @@ type WriteFileTool struct {
fs fileSystem
}
-func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool {
+func NewWriteFileTool(
+ workspace string,
+ restrict bool,
+ allowPaths ...[]*regexp.Regexp,
+) *WriteFileTool {
var patterns []*regexp.Regexp
if len(allowPaths) > 0 {
patterns = allowPaths[0]
@@ -536,7 +910,9 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR
if !overwrite {
if _, err := t.fs.Open(path); err == nil {
- return ErrorResult(fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path))
+ return ErrorResult(
+ fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path),
+ )
}
}
diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go
index 0b4dd310b..bfbc1f46e 100644
--- a/pkg/tools/filesystem_test.go
+++ b/pkg/tools/filesystem_test.go
@@ -18,7 +18,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
testFile := filepath.Join(tmpDir, "test.txt")
os.WriteFile(testFile, []byte("test content"), 0o644)
- tool := NewReadFileTool("", false, MaxReadFileSize)
+ tool := NewReadFileBytesTool("", false, MaxReadFileSize)
ctx := context.Background()
args := map[string]any{
"path": testFile,
@@ -45,7 +45,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
- tool := NewReadFileTool("", false, MaxReadFileSize)
+ tool := NewReadFileBytesTool("", false, MaxReadFileSize)
ctx := context.Background()
args := map[string]any{
"path": "/nonexistent_file_12345.txt",
@@ -59,8 +59,13 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
}
// Should contain error message
- if !strings.Contains(result.ForLLM, "failed to open file") && !strings.Contains(result.ForUser, "failed to read") {
- t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
+ if !strings.Contains(result.ForLLM, "failed to open file") &&
+ !strings.Contains(result.ForUser, "failed to open") {
+ t.Errorf(
+ "Expected error message, got ForLLM: %s, ForUser: %s",
+ result.ForLLM,
+ result.ForUser,
+ )
}
}
@@ -78,7 +83,8 @@ func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) {
}
// Should mention required parameter
- if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") {
+ if !strings.Contains(result.ForLLM, "path is required") &&
+ !strings.Contains(result.ForUser, "path is required") {
t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM)
}
}
@@ -297,7 +303,12 @@ func TestFilesystemTool_WriteFile_OverwriteSandboxed(t *testing.T) {
"content": "replaced in sandbox",
"overwrite": true,
})
- assert.False(t, result.IsError, "expected success in sandbox mode with overwrite=true, got: %s", result.ForLLM)
+ assert.False(
+ t,
+ result.IsError,
+ "expected success in sandbox mode with overwrite=true, got: %s",
+ result.ForLLM,
+ )
data, err := os.ReadFile(filepath.Join(workspace, testFile))
assert.NoError(t, err)
@@ -325,7 +336,8 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
}
// Should list files and directories
- if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") {
+ if !strings.Contains(result.ForLLM, "file1.txt") ||
+ !strings.Contains(result.ForLLM, "file2.txt") {
t.Errorf("Expected files in listing, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "subdir") {
@@ -349,8 +361,13 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
}
// Should contain error message
- if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") {
- t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
+ if !strings.Contains(result.ForLLM, "failed to read") &&
+ !strings.Contains(result.ForUser, "failed to read") {
+ t.Errorf(
+ "Expected error message, got ForLLM: %s, ForUser: %s",
+ result.ForLLM,
+ result.ForUser,
+ )
}
}
@@ -397,7 +414,8 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
// os.Root might return different errors depending on platform/implementation
// but it definitely should error.
// Our wrapper returns "access denied or file not found"
- if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") &&
+ if !strings.Contains(result.ForLLM, "access denied") &&
+ !strings.Contains(result.ForLLM, "file not found") &&
!strings.Contains(result.ForLLM, "no such file") {
t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
}
@@ -416,10 +434,20 @@ func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) {
})
// We EXPECT IsError=true (access blocked due to empty workspace)
- assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM)
+ assert.True(
+ t,
+ result.IsError,
+ "Security Regression: Empty workspace allowed access! content: %s",
+ result.ForLLM,
+ )
// Verify it failed for the right reason
- assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error")
+ assert.Contains(
+ t,
+ result.ForLLM,
+ "workspace is not defined",
+ "Expected 'workspace is not defined' error",
+ )
}
// TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases:
@@ -653,7 +681,10 @@ func TestWhitelistFs_BlocksSymlinkEscapeInAllowedDir(t *testing.T) {
patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))}
tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns)
- result := tool.Execute(context.Background(), map[string]any{"path": filepath.Join(linkPath, "secret.txt")})
+ result := tool.Execute(
+ context.Background(),
+ map[string]any{"path": filepath.Join(linkPath, "secret.txt")},
+ )
if !result.IsError {
t.Fatalf("expected symlink escape from allowed dir to be blocked, got: %s", result.ForLLM)
}
@@ -726,7 +757,6 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "pagination_test.txt")
- // Create a test file with exactly 26 bytes of content
fullContent := "abcdefghijklmnopqrstuvwxyz"
err := os.WriteFile(testFile, []byte(fullContent), 0o644)
if err != nil {
@@ -748,15 +778,12 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
t.Fatalf("Chunk 1 failed: %s", result1.ForLLM)
}
- // Expect the first 10 characters
if !strings.Contains(result1.ForLLM, "abcdefghij") {
t.Errorf("Chunk 1 should contain 'abcdefghij', got: %s", result1.ForLLM)
}
- // Expect the header to indicate the file is truncated
if !strings.Contains(result1.ForLLM, "[TRUNCATED") {
t.Errorf("Chunk 1 header should indicate truncation, got: %s", result1.ForLLM)
}
- // Expect the header to suggest the next offset (10)
if !strings.Contains(result1.ForLLM, "offset=10") {
t.Errorf("Chunk 1 header should suggest next offset=10, got: %s", result1.ForLLM)
}
@@ -773,17 +800,14 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
t.Fatalf("Chunk 2 failed: %s", result2.ForLLM)
}
- // Expect the next 10 characters
if !strings.Contains(result2.ForLLM, "klmnopqrst") {
t.Errorf("Chunk 2 should contain 'klmnopqrst', got: %s", result2.ForLLM)
}
- // Expect the header to suggest the next offset (20)
if !strings.Contains(result2.ForLLM, "offset=20") {
t.Errorf("Chunk 2 header should suggest next offset=20, got: %s", result2.ForLLM)
}
// Step 3: Read the final chunk (remaining 6 bytes) ---
- // We ask for 10 bytes, but only 6 are left in the file
args3 := map[string]any{
"path": testFile,
"offset": 20,
@@ -795,16 +819,12 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
t.Fatalf("Chunk 3 failed: %s", result3.ForLLM)
}
- // Expect the last 6 characters
if !strings.Contains(result3.ForLLM, "uvwxyz") {
t.Errorf("Chunk 3 should contain 'uvwxyz', got: %s", result3.ForLLM)
}
- // Expect the header to indicate the end of the file
if !strings.Contains(result3.ForLLM, "[END OF FILE") {
t.Errorf("Chunk 3 header should indicate end of file, got: %s", result3.ForLLM)
}
-
- // Ensure no TRUNCATED message is present in the final chunk
if strings.Contains(result3.ForLLM, "[TRUNCATED") {
t.Errorf("Chunk 3 header should NOT indicate truncation, got: %s", result3.ForLLM)
}
@@ -816,7 +836,6 @@ func TestReadFileTool_OffsetBeyondEOF(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "short.txt")
- // create a file of only 5 bytes
err := os.WriteFile(testFile, []byte("12345"), 0o644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
@@ -827,19 +846,393 @@ func TestReadFileTool_OffsetBeyondEOF(t *testing.T) {
args := map[string]any{
"path": testFile,
- "offset": int64(100), // Offset beyond the end of the file
+ "offset": int64(100),
}
result := tool.Execute(ctx, args)
- // It should not be classified as a tool execution error
if result.IsError {
t.Errorf("A mistake was not expected, obtained IsError=true: %s", result.ForLLM)
}
- // Must return EXACTLY the string provided in the code
expectedMsg := "[END OF FILE - no content at this offset]"
if result.ForLLM != expectedMsg {
t.Errorf("The message %q was expected, obtained: %q", expectedMsg, result.ForLLM)
}
}
+
+func TestReadFileLinesTool_ChunkedReading(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "pagination_lines.txt")
+
+ fullContent := strings.Join([]string{
+ "line 1",
+ "line 2",
+ "line 3",
+ "line 4",
+ "line 5",
+ "line 6",
+ }, "\n") + "\n"
+ err := os.WriteFile(testFile, []byte(fullContent), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+
+ result1 := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ "max_lines": 2,
+ })
+ if result1.IsError {
+ t.Fatalf("Chunk 1 failed: %s", result1.ForLLM)
+ }
+ if !strings.Contains(result1.ForLLM, "1|line 1\n2|line 2\n") {
+ t.Fatalf("expected first two lines, got: %s", result1.ForLLM)
+ }
+ if !strings.Contains(result1.ForLLM, "lines 1-2") {
+ t.Fatalf("expected line range 1-2, got: %s", result1.ForLLM)
+ }
+ if !strings.Contains(result1.ForLLM, "start_line=3") {
+ t.Fatalf("expected continuation start_line=3, got: %s", result1.ForLLM)
+ }
+ if !strings.Contains(result1.ForLLM, "max_lines=2") {
+ t.Fatalf("expected continuation max_lines=2, got: %s", result1.ForLLM)
+ }
+
+ result2 := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 3,
+ "max_lines": 2,
+ })
+ if result2.IsError {
+ t.Fatalf("Chunk 2 failed: %s", result2.ForLLM)
+ }
+ if !strings.Contains(result2.ForLLM, "3|line 3\n4|line 4\n") {
+ t.Fatalf("expected middle chunk, got: %s", result2.ForLLM)
+ }
+ if !strings.Contains(result2.ForLLM, "start_line=5") {
+ t.Fatalf("expected continuation start_line=5, got: %s", result2.ForLLM)
+ }
+ if !strings.Contains(result2.ForLLM, "max_lines=2") {
+ t.Fatalf("expected continuation max_lines=2, got: %s", result2.ForLLM)
+ }
+
+ result3 := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 5,
+ "max_lines": 2,
+ })
+ if result3.IsError {
+ t.Fatalf("Chunk 3 failed: %s", result3.ForLLM)
+ }
+ if !strings.Contains(result3.ForLLM, "5|line 5\n6|line 6\n") {
+ t.Fatalf("expected final chunk, got: %s", result3.ForLLM)
+ }
+ if !strings.Contains(result3.ForLLM, "[END OF FILE") {
+ t.Fatalf("expected EOF marker, got: %s", result3.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_DefaultOffsetAndRemainingLines(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "default_lines.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ })
+ if result.IsError {
+ t.Fatalf("Execute() error = %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "1|line 1\n2|line 2\n3|line 3\n") {
+ t.Fatalf("expected remaining lines by default, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "lines 1-3") {
+ t.Fatalf("expected line range 1-3, got: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileTool_LegacyLengthUsesByteModeForText(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "legacy_bytes.txt")
+
+ err := os.WriteFile(testFile, []byte("abcdefghijklmnopqrstuvwxyz"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileBytesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "offset": 10,
+ "length": 5,
+ })
+ if result.IsError {
+ t.Fatalf("Execute() error = %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "read: bytes 10-14") {
+ t.Fatalf("expected byte-based header, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "klmno") {
+ t.Fatalf("expected byte chunk content, got: %s", result.ForLLM)
+ }
+ if strings.Contains(result.ForLLM, "lines ") {
+ t.Fatalf("expected legacy byte mode, got line-based header: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_OffsetBeyondEOF(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "short_lines.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": int64(100),
+ })
+ if result.IsError {
+ t.Fatalf("unexpected error: %s", result.ForLLM)
+ }
+ if result.ForLLM != "[END OF FILE - no content at or after start_line=100]" {
+ t.Fatalf("unexpected EOF message: %q", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_RegistryValidationSupportsMaxLinesAndRejectsLimit(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "registry_lines.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ reg := NewToolRegistry()
+ reg.Register(NewReadFileLinesTool(tmpDir, false, MaxReadFileSize))
+
+ result := reg.Execute(context.Background(), "read_file", map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ "max_lines": 1,
+ })
+ if result.IsError {
+ t.Fatalf("expected max_lines to pass registry validation, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "1|line 1\n") {
+ t.Fatalf("expected first line via max_lines, got: %s", result.ForLLM)
+ }
+
+ result = reg.Execute(context.Background(), "read_file", map[string]any{
+ "path": testFile,
+ "start_line": 2,
+ "limit": 1,
+ })
+ if !result.IsError {
+ t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "unexpected property \"limit\"") {
+ t.Fatalf("expected registry validation error for limit, got: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_RejectsOffset(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "legacy_offset.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ "offset": 1,
+ })
+ if !result.IsError {
+ t.Fatalf("expected offset to be rejected, got success: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "offset is not supported in line mode; use start_line") {
+ t.Fatalf("unexpected error for offset in line mode: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_RejectsLength(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "legacy_length.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ "length": 1,
+ })
+ if !result.IsError {
+ t.Fatalf("expected length to be rejected, got success: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "length is not supported in line mode; use max_lines") {
+ t.Fatalf("unexpected error for length in line mode: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_RejectsLimit(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "legacy_limit.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ "limit": 1,
+ })
+ if !result.IsError {
+ t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "limit is not supported in line mode; use max_lines") {
+ t.Fatalf("unexpected error for limit in line mode: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_BinaryFileRejected(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "binary.dat")
+
+ data := []byte{0x00, 0x01, 'A', 'B', 'C', 'D', 'E', 'F'}
+ err := os.WriteFile(testFile, data, 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ })
+ if !result.IsError {
+ t.Fatalf("expected binary file rejection in line mode, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "switch read_file mode to 'bytes'") {
+ t.Fatalf("expected binary file rejection message, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "mode to 'bytes'") {
+ t.Fatalf("expected suggestion to switch read_file mode, got: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_TruncatesSingleLongLineAtByteBudget(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "long_line.txt")
+
+ content := "first line\n" + strings.Repeat("x", 70*1024) + "\n"
+ err := os.WriteFile(testFile, []byte(content), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ })
+ if result.IsError {
+ t.Fatalf("Execute() error = %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "was cut mid-line") {
+ t.Fatalf("expected explicit mid-line truncation warning, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "1|first line\n") {
+ t.Fatalf("expected the first line with line prefix, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "2|") {
+ t.Fatalf("expected line prefix for the truncated line, got: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_NoTrailingNewline(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "no_trailing_newline.txt")
+
+ err := os.WriteFile(testFile, []byte("line 1\nline 2"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ })
+ if result.IsError {
+ t.Fatalf("Execute() error = %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "1|line 1\n2|line 2") {
+ t.Fatalf(
+ "expected final line without trailing newline to be preserved, got: %s",
+ result.ForLLM,
+ )
+ }
+ if !strings.Contains(result.ForLLM, "[END OF FILE - no further content.]") {
+ t.Fatalf("expected EOF marker, got: %s", result.ForLLM)
+ }
+}
+
+func TestReadFileLinesTool_ExactByteBudgetBoundaryIncludesPrefix(t *testing.T) {
+ tmpDir := t.TempDir()
+ testFile := filepath.Join(tmpDir, "exact_boundary.txt")
+
+ err := os.WriteFile(testFile, []byte("1234567\nsecond line\n"), 0o644)
+ if err != nil {
+ t.Fatalf("Failed to write test file: %v", err)
+ }
+
+ tool := NewReadFileLinesTool(tmpDir, false, 10)
+ result := tool.Execute(context.Background(), map[string]any{
+ "path": testFile,
+ "start_line": 1,
+ })
+ if result.IsError {
+ t.Fatalf("Execute() error = %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "1|1234567\n") {
+ t.Fatalf(
+ "expected first line to fit exactly in the byte budget with its prefix, got: %s",
+ result.ForLLM,
+ )
+ }
+ if strings.Contains(result.ForLLM, "2|") {
+ t.Fatalf(
+ "expected second line to be excluded once the exact output byte budget was reached, got: %s",
+ result.ForLLM,
+ )
+ }
+ if !strings.Contains(result.ForLLM, "file_bytes: 8 | output_bytes: 10") {
+ t.Fatalf("expected separate file/output byte counters, got: %s", result.ForLLM)
+ }
+ if !strings.Contains(result.ForLLM, "start_line=2") {
+ t.Fatalf("expected continuation at line 2, got: %s", result.ForLLM)
+ }
+}