fix: resolve merge conflicts

This commit is contained in:
corevibe555 2026-04-02 13:15:57 +02:00
commit e4345ce34c
40 changed files with 1546 additions and 360 deletions

View file

@ -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
path: dist/*.dmg

View file

@ -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 :
<table>
<tr>
<td><img src="assets/fui_main_page.jpg" width="200"></td>
<td><img src="assets/fui_web_page.jpg" width="200"></td>
<td><img src="assets/fui_log_page.jpg" width="200"></td>
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
</tr>
</table>
Téléchargez l'APK depuis [picoclaw.io](https://picoclaw.io/download/) et installez-le directement. Pas besoin de Termux !
**Option 2 : Termux**
<details>
<summary><b>Terminal Launcher (pour les environnements à ressources limitées)</b></summary>
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
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
**Option 2 : Installation APK**
Téléchargez l'APK depuis [picoclaw.io](https://picoclaw.io/download/) et installez-le directement. Pas besoin de Termux !
<details>
<summary><b>Terminal Launcher (pour les environnements à ressources limitées)</b></summary>
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**

View file

@ -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:
<table>
<tr>
<td><img src="assets/fui_main_page.jpg" width="200"></td>
<td><img src="assets/fui_web_page.jpg" width="200"></td>
<td><img src="assets/fui_log_page.jpg" width="200"></td>
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
</tr>
</table>
Unduh APK dari [picoclaw.io](https://picoclaw.io/download/) dan instal langsung. Tanpa Termux!
**Opsi 2: Termux**
<details>
<summary><b>Terminal Launcher (untuk lingkungan dengan sumber daya terbatas)</b></summary>
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
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
**Opsi 2: Instal APK**
Unduh APK dari [picoclaw.io](https://picoclaw.io/download/) dan instal langsung. Tanpa Termux!
<details>
<summary><b>Terminal Launcher (untuk lingkungan dengan sumber daya terbatas)</b></summary>
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**

View file

@ -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:
<table>
<tr>
<td><img src="assets/fui_main_page.jpg" width="200"></td>
<td><img src="assets/fui_web_page.jpg" width="200"></td>
<td><img src="assets/fui_log_page.jpg" width="200"></td>
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
</tr>
</table>
Scarica l'APK da [picoclaw.io](https://picoclaw.io/download/) e installa direttamente. Senza Termux!
**Opzione 2: Termux**
<details>
<summary><b>Terminal Launcher (per ambienti con risorse limitate)</b></summary>
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
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
**Opzione 2: Installazione APK**
Scarica l'APK da [picoclaw.io](https://picoclaw.io/download/) e installa direttamente. Senza Termux!
<details>
<summary><b>Terminal Launcher (per ambienti con risorse limitate)</b></summary>
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**

View file

@ -303,7 +303,25 @@ TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.i
10 年前のスマホに第二の人生をPicoClaw でスマート AI アシスタントに変身させましょう。
**オプション 1: Termux現在利用可能**
**オプション 1: APK インストール**
プレビュー:
<table>
<tr>
<td><img src="assets/fui_main_page.jpg" width="200"></td>
<td><img src="assets/fui_web_page.jpg" width="200"></td>
<td><img src="assets/fui_log_page.jpg" width="200"></td>
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
</tr>
</table>
[picoclaw.io](https://picoclaw.io/download/) から APK をダウンロードして直接インストール。Termux 不要!
**オプション 2: Termux**
<details>
<summary><b>Terminal Launcherリソース制約環境向け</b></summary>
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 ファイル
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
**オプション 2: APK インストール**
[picoclaw.io](https://picoclaw.io/download/) から APK をダウンロードして直接インストール。Termux 不要!
<details>
<summary><b>Terminal Launcherリソース制約環境向け</b></summary>
`picoclaw` コアバイナリのみが利用可能な最小環境Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。
**1. 初期化**

View file

@ -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:
<table>
<tr>
<td><img src="assets/fui_main_page.jpg" width="200"></td>
<td><img src="assets/fui_web_page.jpg" width="200"></td>
<td><img src="assets/fui_log_page.jpg" width="200"></td>
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
</tr>
</table>
Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install directly. No Termux required!
**Option 2: Termux**
<details>
<summary><b>Terminal Launcher (for resource-constrained environments)</b></summary>
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.
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
**Option 2: APK Install**
Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install directly. No Termux required!
<details>
<summary><b>Terminal Launcher (for resource-constrained environments)</b></summary>
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**

View file

@ -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:
<table>
<tr>
<td><img src="assets/fui_main_page.jpg" width="200"></td>
<td><img src="assets/fui_web_page.jpg" width="200"></td>
<td><img src="assets/fui_log_page.jpg" width="200"></td>
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
</tr>
</table>
Muat turun APK dari [picoclaw.io](https://picoclaw.io/download/) dan pasang secara langsung. Tiada Termux diperlukan!
**Pilihan 2: Termux**
<details>
<summary><b>Pelancar Terminal (untuk persekitaran terhad sumber)</b></summary>
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
<img src="assets/termux.jpg" alt="PicoClaw pada Termux" width="512">
**Pilihan 2: Pasang APK**
Muat turun APK dari [picoclaw.io](https://picoclaw.io/download/) dan pasang secara langsung. Tiada Termux diperlukan!
<details>
<summary><b>Pelancar Terminal (untuk persekitaran terhad sumber)</b></summary>
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**

View file

@ -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:
<table>
<tr>
<td><img src="assets/fui_main_page.jpg" width="200"></td>
<td><img src="assets/fui_web_page.jpg" width="200"></td>
<td><img src="assets/fui_log_page.jpg" width="200"></td>
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
</tr>
</table>
Baixe o APK de [picoclaw.io](https://picoclaw.io/download/) e instale diretamente. Sem necessidade de Termux!
**Opção 2: Termux**
<details>
<summary><b>Terminal Launcher (para ambientes com recursos limitados)</b></summary>
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ç
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
**Opção 2: Instalação via APK**
Baixe o APK de [picoclaw.io](https://picoclaw.io/download/) e instale diretamente. Sem necessidade de Termux!
<details>
<summary><b>Terminal Launcher (para ambientes com recursos limitados)</b></summary>
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**

View file

@ -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:
<table>
<tr>
<td><img src="assets/fui_main_page.jpg" width="200"></td>
<td><img src="assets/fui_web_page.jpg" width="200"></td>
<td><img src="assets/fui_log_page.jpg" width="200"></td>
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
</tr>
</table>
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**
<details>
<summary><b>Terminal Launcher (cho môi trường hạn chế tài nguyên)</b></summary>
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
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
**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!
<details>
<summary><b>Terminal Launcher (cho môi trường hạn chế tài nguyên)</b></summary>
Đố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**

View file

@ -303,7 +303,25 @@ picoclaw-launcher-tui
让你十年前的旧手机焕发新生!将它变成你的 AI 助手。
**方式一Termux现已可用**
**方式一APK 安装**
预览:
<table>
<tr>
<td><img src="assets/fui_main_page.jpg" width="200"></td>
<td><img src="assets/fui_web_page.jpg" width="200"></td>
<td><img src="assets/fui_log_page.jpg" width="200"></td>
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
</tr>
</table>
从 [picoclaw.io](https://picoclaw.io/download/) 下载 APK 并直接安装,无需 Termux
**方式二Termux**
<details>
<summary><b>Terminal Launcher适用于资源受限环境</b></summary>
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 文件系统布
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
**方式二APK 安装**
从 [picoclaw.io](https://picoclaw.io/download/) 下载 APK 并直接安装,无需 Termux
<details>
<summary><b>Terminal Launcher适用于资源受限环境</b></summary>
对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI可通过命令行和 JSON 配置文件完成所有配置。
**1. 初始化**

BIN
assets/fui_log_page.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
assets/fui_main_page.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
assets/fui_setting_page.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
assets/fui_web_page.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

View file

@ -421,7 +421,8 @@
"enabled": true
},
"read_file": {
"enabled": true
"enabled": true,
"mode": "bytes"
},
"send_tts": {
"enabled": false

View file

@ -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 |

View file

@ -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
}
```

View file

@ -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. `<current-working-directory>/skills` (builtin)
Per configurazioni avanzate/di test, puoi sovrascrivere la directory radice delle skill builtin con:
```bash
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
```
### Politica Unificata di Esecuzione dei Comandi
- I comandi slash generici vengono eseguiti tramite un unico percorso in `pkg/agent/loop.go` via `commands.Executor`.
- Gli adattatori dei canali non consumano più localmente i comandi generici; inoltrano il testo in entrata al percorso bus/agent. Telegram registra ancora automaticamente i comandi supportati all'avvio.
- Un comando slash sconosciuto (ad esempio `/foo`) viene passato all'elaborazione LLM come se fosse un messaggio dell'utente.
- Un comando registrato ma non supportato sul canale corrente (ad esempio `/show` su WhatsApp) restituisce un errore esplicito all'utente e interrompe l'elaborazione.
### 🔒 Sandbox di Sicurezza
PicoClaw esegue in un ambiente sandboxed per impostazione predefinita. L'agent può accedere solo ai file ed eseguire comandi all'interno del workspace configurato.
#### Configurazione Predefinita
```json
{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true
}
}
}
```
| Opzione | Predefinito | Descrizione |
| ----------------------- | ----------------------- | ---------------------------------------------------- |
| `workspace` | `~/.picoclaw/workspace` | Directory di lavoro dell'agent |
| `restrict_to_workspace` | `true` | Limita l'accesso a file/comandi al workspace |
#### Strumenti Protetti
Quando `restrict_to_workspace: true`, i seguenti strumenti sono in sandbox:
| Strumento | Funzione | Restrizione |
| ------------- | ------------------------- | ---------------------------------------------------- |
| `read_file` | Legge file | Solo file all'interno del workspace |
| `write_file` | Scrive file | Solo file all'interno del workspace |
| `list_dir` | Elenca directory | Solo directory all'interno del workspace |
| `edit_file` | Modifica file | Solo file all'interno del workspace |
| `append_file` | Aggiunge ai file | Solo file all'interno del workspace |
| `exec` | Esegue comandi | I percorsi dei comandi devono essere nel workspace |
#### Protezione Exec Aggiuntiva
Anche con `restrict_to_workspace: false`, lo strumento `exec` blocca questi comandi pericolosi:
* `rm -rf`, `del /f`, `rmdir /s` — Cancellazione di massa
* `format`, `mkfs`, `diskpart` — Formattazione del disco
* `dd if=` — Imaging del disco
* Scrittura su `/dev/sd[a-z]` — Scritture dirette su disco
* `shutdown`, `reboot`, `poweroff` — Spegnimento del sistema
* Fork bomb `:(){ :|:& };:`
### Controllo Accesso ai File
| Chiave di configurazione | Tipo | Predefinito | Descrizione |
|--------------------------|------|-------------|-------------|
| `tools.allow_read_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la lettura al di fuori del workspace |
| `tools.allow_write_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la scrittura al di fuori del workspace |
### Sicurezza Exec
| Chiave di configurazione | Tipo | Predefinito | Descrizione |
|--------------------------|------|-------------|-------------|
| `tools.exec.allow_remote` | bool | `false` | Consente lo strumento exec da canali remoti (Telegram/Discord ecc.) |
| `tools.exec.enable_deny_patterns` | bool | `true` | Abilita l'intercettazione dei comandi pericolosi |
| `tools.exec.custom_deny_patterns` | string[] | `[]` | Pattern regex personalizzati da bloccare |
| `tools.exec.custom_allow_patterns` | string[] | `[]` | Pattern regex personalizzati da consentire |
> **Nota di sicurezza:** La protezione dei symlink è abilitata per impostazione predefinita — tutti i percorsi file vengono risolti tramite `filepath.EvalSymlinks` prima del confronto con la whitelist, prevenendo attacchi di escape tramite symlink.
#### Limitazione Nota: Processi Figlio degli Strumenti di Build
Il controllo di sicurezza exec ispeziona solo la riga di comando avviata direttamente da PicoClaw. Non ispeziona ricorsivamente i processi figlio generati da strumenti di sviluppo consentiti come `make`, `go run`, `cargo`, `npm run` o script di build personalizzati.
Ciò significa che un comando di primo livello può comunque compilare o avviare altri binari dopo aver superato il controllo iniziale. In pratica, tratta gli script di build, i Makefile, gli script di pacchetti e i binari generati come codice eseguibile che richiede lo stesso livello di revisione di un comando shell diretto.
Per ambienti ad alto rischio:
* Esamina gli script di build prima dell'esecuzione.
* Preferisci l'approvazione/revisione manuale per i workflow di compilazione ed esecuzione.
* Esegui PicoClaw in un container o VM se hai bisogno di un isolamento più forte di quello fornito dal controllo integrato.
#### Esempi di Errore
```
[ERROR] tool: Tool execution failed
{tool=exec, error=Command blocked by safety guard (path outside working dir)}
```
```
[ERROR] tool: Tool execution failed
{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
```
#### Disabilitare le Restrizioni (Rischio di Sicurezza)
Se hai bisogno che l'agent acceda a percorsi al di fuori del workspace:
**Metodo 1: File di configurazione**
```json
{
"agents": {
"defaults": {
"restrict_to_workspace": false
}
}
}
```
**Metodo 2: Variabile d'ambiente**
```bash
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
```
> ⚠️ **Attenzione**: Disabilitare questa restrizione consente all'agent di accedere a qualsiasi percorso sul tuo sistema. Usare con cautela solo in ambienti controllati.
#### Coerenza dei Confini di Sicurezza
L'impostazione `restrict_to_workspace` si applica in modo coerente a tutti i percorsi di esecuzione:
| Percorso di esecuzione | Confine di sicurezza |
| ---------------------- | --------------------------------- |
| Main Agent | `restrict_to_workspace` ✅ |
| Subagent / Spawn | Eredita la stessa restrizione ✅ |
| Heartbeat tasks | Eredita la stessa restrizione ✅ |
Tutti i percorsi condividono la stessa restrizione del workspace — non è possibile aggirare il confine di sicurezza tramite subagent o task pianificati.
### Heartbeat (Task Periodici)
PicoClaw può eseguire task periodici automaticamente. Crea un file `HEARTBEAT.md` nel tuo workspace:
```markdown
# Periodic Tasks
- Check my email for important messages
- Review my calendar for upcoming events
- Check the weather forecast
```
L'agent leggerà questo file ogni 30 minuti (configurabile) ed eseguirà tutti i task usando gli strumenti disponibili.
#### Task Asincroni con Spawn
Per task di lunga durata (ricerca web, chiamate API), usa lo strumento `spawn` per creare un **subagent**:
```markdown
# Periodic Tasks
```

View file

@ -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[] | はい* | 認証キー。複数キーでリクエストごとのローテーションが可能。ローカル providerOllama、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
}
```

View file

@ -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
}
```

View file

@ -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
}
```

View file

@ -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
}
```

View file

@ -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[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 providerOllama、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
}
```

View file

@ -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))

View file

@ -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()

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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 {

View file

@ -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")

View file

@ -487,6 +487,7 @@ func DefaultConfig() *Config {
},
ReadFile: ReadFileToolConfig{
Enabled: true,
Mode: ReadFileModeBytes,
MaxReadFileSize: 64 * 1024, // 64KB
},
Spawn: ToolConfig{

View file

@ -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)

View file

@ -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 {

View file

@ -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 {

View file

@ -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)

View file

@ -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

View file

@ -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")

View file

@ -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),
),
}
}

View file

@ -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)
}

View file

@ -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),
)
}
}

View file

@ -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)
}
}