fix: resolve merge conflicts
This commit is contained in:
commit
e4345ce34c
40 changed files with 1546 additions and 360 deletions
2
.github/workflows/create_dmg.yml
vendored
2
.github/workflows/create_dmg.yml
vendored
|
|
@ -54,7 +54,7 @@ jobs:
|
||||||
"dist/picoclaw-${{ matrix.arch }}.dmg" \
|
"dist/picoclaw-${{ matrix.arch }}.dmg" \
|
||||||
"build/PicoClaw Launcher.app"
|
"build/PicoClaw Launcher.app"
|
||||||
|
|
||||||
# 6. 上传文件到 GitHub Artifacts (供你下载)
|
# 7. 上传文件到 GitHub Artifacts (供你下载)
|
||||||
- name: Upload DMG
|
- name: Upload DMG
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|
|
||||||
27
README.fr.md
27
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.
|
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)
|
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 :
|
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">
|
<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.
|
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**
|
**1. Initialiser**
|
||||||
|
|
|
||||||
27
README.id.md
27
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.
|
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)
|
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:
|
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">
|
<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.
|
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**
|
**1. Inisialisasi**
|
||||||
|
|
|
||||||
27
README.it.md
27
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.
|
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)
|
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:
|
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">
|
<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.
|
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**
|
**1. Inizializza**
|
||||||
|
|
|
||||||
27
README.ja.md
27
README.ja.md
|
|
@ -303,7 +303,25 @@ TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.i
|
||||||
|
|
||||||
10 年前のスマホに第二の人生を!PicoClaw でスマート AI アシスタントに変身させましょう。
|
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 で検索)
|
1. [Termux](https://github.com/termux/termux-app) をインストール([GitHub Releases](https://github.com/termux/termux-app/releases) からダウンロード、または F-Droid / Google Play で検索)
|
||||||
2. 以下のコマンドを実行:
|
2. 以下のコマンドを実行:
|
||||||
|
|
@ -320,13 +338,6 @@ termux-chroot ./picoclaw onboard # chroot で標準的な Linux ファイル
|
||||||
|
|
||||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
<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 設定ファイルですべてを設定できます。
|
`picoclaw` コアバイナリのみが利用可能な最小環境(Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。
|
||||||
|
|
||||||
**1. 初期化**
|
**1. 初期化**
|
||||||
|
|
|
||||||
27
README.md
27
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.
|
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)
|
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:
|
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">
|
<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.
|
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**
|
**1. Initialize**
|
||||||
|
|
|
||||||
27
README.my.md
27
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.
|
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)
|
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:
|
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">
|
<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.
|
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**
|
**1. Mulakan**
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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)
|
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:
|
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">
|
<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.
|
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**
|
**1. Inicializar**
|
||||||
|
|
|
||||||
27
README.vi.md
27
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.
|
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)
|
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:
|
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">
|
<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.
|
Đố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**
|
**1. Khởi tạo**
|
||||||
|
|
|
||||||
27
README.zh.md
27
README.zh.md
|
|
@ -303,7 +303,25 @@ picoclaw-launcher-tui
|
||||||
|
|
||||||
让你十年前的旧手机焕发新生!将它变成你的 AI 助手。
|
让你十年前的旧手机焕发新生!将它变成你的 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 中搜索)
|
1. 安装 [Termux](https://github.com/termux/termux-app)(可从 [GitHub Releases](https://github.com/termux/termux-app/releases) 下载,或在 F-Droid / Google Play 中搜索)
|
||||||
2. 执行以下命令:
|
2. 执行以下命令:
|
||||||
|
|
@ -320,13 +338,6 @@ termux-chroot ./picoclaw onboard # chroot 提供标准 Linux 文件系统布
|
||||||
|
|
||||||
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
|
<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 配置文件完成所有配置。
|
对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI),可通过命令行和 JSON 配置文件完成所有配置。
|
||||||
|
|
||||||
**1. 初始化**
|
**1. 初始化**
|
||||||
|
|
|
||||||
BIN
assets/fui_log_page.jpg
Normal file
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
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
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
BIN
assets/fui_web_page.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
|
|
@ -421,7 +421,8 @@
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"read_file": {
|
"read_file": {
|
||||||
"enabled": true
|
"enabled": true,
|
||||||
|
"mode": "bytes"
|
||||||
},
|
},
|
||||||
"send_tts": {
|
"send_tts": {
|
||||||
"enabled": false
|
"enabled": false
|
||||||
|
|
|
||||||
|
|
@ -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_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace |
|
||||||
| `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing 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
|
### Exec Security
|
||||||
|
|
||||||
| Config Key | Type | Default | Description |
|
| Config Key | Type | Default | Description |
|
||||||
|
|
|
||||||
|
|
@ -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
|
#### Exemples par Vendor
|
||||||
|
|
||||||
**OpenAI**
|
**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",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_keys": ["sk-..."],
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
```
|
|
||||||
|
|
@ -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**
|
**OpenAI**
|
||||||
|
|
@ -201,6 +219,7 @@ Anthropic API への直接アクセスや、Anthropic のネイティブメッ
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_keys": ["sk-..."],
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
#### 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.
|
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",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_keys": ["sk-..."],
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
#### Exemplos por Vendor
|
||||||
|
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
|
|
@ -190,6 +208,7 @@ Para acesso direto à API Anthropic ou endpoints personalizados que suportam ape
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_keys": ["sk-..."],
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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
|
#### Ví Dụ Theo Vendor
|
||||||
|
|
||||||
**OpenAI**
|
**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",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_keys": ["sk-..."],
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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。
|
你可以通过 `voice.model_name` 为语音转录指定一个专用模型。这样可以直接复用已经配置好的、支持音频输入的多模态 provider,而不必只依赖 Groq。
|
||||||
|
|
@ -234,6 +252,7 @@ PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_keys": ["sk-..."],
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,12 @@ func NewAgentInstance(
|
||||||
|
|
||||||
if cfg.Tools.IsToolEnabled("read_file") {
|
if cfg.Tools.IsToolEnabled("read_file") {
|
||||||
maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize
|
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") {
|
if cfg.Tools.IsToolEnabled("write_file") {
|
||||||
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) {
|
||||||
workspace := t.TempDir()
|
workspace := t.TempDir()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -513,6 +514,8 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
||||||
|
|
||||||
dispatchCtx, cancel := context.WithCancel(ctx)
|
dispatchCtx, cancel := context.WithCancel(ctx)
|
||||||
m.dispatchTask = &asyncTask{cancel: cancel}
|
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 {
|
for name, channel := range m.channels {
|
||||||
logger.InfoCF("channels", "Starting channel", map[string]any{
|
logger.InfoCF("channels", "Starting channel", map[string]any{
|
||||||
|
|
@ -523,6 +526,8 @@ func (m *Manager) StartAll(ctx context.Context) error {
|
||||||
"channel": name,
|
"channel": name,
|
||||||
"error": err.Error(),
|
"error": err.Error(),
|
||||||
})
|
})
|
||||||
|
failedStarts = append(failedStarts, fmt.Errorf("channel %s: %w", name, err))
|
||||||
|
failedNames = append(failedNames, name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Lazily create worker only after channel starts successfully
|
// 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)
|
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
|
// Start the dispatcher that reads from the bus and routes to workers
|
||||||
go m.dispatchOutbound(dispatchCtx)
|
go m.dispatchOutbound(dispatchCtx)
|
||||||
go m.dispatchOutboundMedia(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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,8 @@ import (
|
||||||
type mockChannel struct {
|
type mockChannel struct {
|
||||||
BaseChannel
|
BaseChannel
|
||||||
sendFn func(ctx context.Context, msg bus.OutboundMessage) error
|
sendFn func(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
|
startFn func(ctx context.Context) error
|
||||||
|
stopFn func(ctx context.Context) error
|
||||||
sentMessages []bus.OutboundMessage
|
sentMessages []bus.OutboundMessage
|
||||||
placeholdersSent int
|
placeholdersSent int
|
||||||
editedMessages int
|
editedMessages int
|
||||||
|
|
@ -33,8 +35,19 @@ func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri
|
||||||
return nil, m.sendFn(ctx, msg)
|
return nil, m.sendFn(ctx, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockChannel) Start(ctx context.Context) error { return nil }
|
func (m *mockChannel) Start(ctx context.Context) error {
|
||||||
func (m *mockChannel) Stop(ctx context.Context) error { return nil }
|
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) {
|
func (m *mockChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
||||||
m.placeholdersSent++
|
m.placeholdersSent++
|
||||||
|
|
@ -86,6 +99,101 @@ func newTestManager() *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
channels: make(map[string]Channel),
|
channels: make(map[string]Channel),
|
||||||
workers: make(map[string]*channelWorker),
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -600,6 +601,8 @@ type ModelConfig struct {
|
||||||
// existing configs, the field is inferred during load: models with API keys
|
// existing configs, the field is inferred during load: models with API keys
|
||||||
// or the reserved "local-model" name are auto-enabled.
|
// or the reserved "local-model" name are auto-enabled.
|
||||||
Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"`
|
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.
|
// isVirtual marks this model as a virtual model generated from multi-key expansion.
|
||||||
// Virtual models should not be persisted to config files.
|
// Virtual models should not be persisted to config files.
|
||||||
|
|
@ -801,8 +804,25 @@ type MediaCleanupConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReadFileToolConfig struct {
|
type ReadFileToolConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
MaxReadFileSize int `json:"max_read_file_size"`
|
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 {
|
type ToolsConfig struct {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestSaveConfig_FilePermissions(t *testing.T) {
|
||||||
if runtime.GOOS == "windows" {
|
if runtime.GOOS == "windows" {
|
||||||
t.Skip("file permission bits are not enforced on Windows")
|
t.Skip("file permission bits are not enforced on Windows")
|
||||||
|
|
|
||||||
|
|
@ -487,6 +487,7 @@ func DefaultConfig() *Config {
|
||||||
},
|
},
|
||||||
ReadFile: ReadFileToolConfig{
|
ReadFile: ReadFileToolConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
Mode: ReadFileModeBytes,
|
||||||
MaxReadFileSize: 64 * 1024, // 64KB
|
MaxReadFileSize: 64 * 1024, // 64KB
|
||||||
},
|
},
|
||||||
Spawn: ToolConfig{
|
Spawn: ToolConfig{
|
||||||
|
|
|
||||||
|
|
@ -41,15 +41,16 @@ type Provider struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
apiBase string
|
apiBase string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
userAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProvider creates a new Anthropic Messages API provider.
|
// NewProvider creates a new Anthropic Messages API provider.
|
||||||
func NewProvider(apiKey, apiBase string) *Provider {
|
func NewProvider(apiKey, apiBase, userAgent string) *Provider {
|
||||||
return NewProviderWithTimeout(apiKey, apiBase, 0)
|
return NewProviderWithTimeout(apiKey, apiBase, userAgent, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProviderWithTimeout creates a provider with custom request timeout.
|
// 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)
|
baseURL := normalizeBaseURL(apiBase)
|
||||||
timeout := defaultRequestTimeout
|
timeout := defaultRequestTimeout
|
||||||
if timeoutSeconds > 0 {
|
if timeoutSeconds > 0 {
|
||||||
|
|
@ -57,8 +58,9 @@ func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provide
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Provider{
|
return &Provider{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
apiBase: baseURL,
|
apiBase: baseURL,
|
||||||
|
userAgent: userAgent,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
},
|
},
|
||||||
|
|
@ -105,6 +107,9 @@ func (p *Provider) Chat(
|
||||||
req.Header.Set("Content-Type", "application/json")
|
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("X-API-Key", p.apiKey) //nolint:canonicalheader // Anthropic API requires exact header name
|
||||||
req.Header.Set("Anthropic-Version", defaultAPIVersion)
|
req.Header.Set("Anthropic-Version", defaultAPIVersion)
|
||||||
|
if p.userAgent != "" {
|
||||||
|
req.Header.Set("User-Agent", p.userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
// Execute request
|
// Execute request
|
||||||
resp, err := p.httpClient.Do(req)
|
resp, err := p.httpClient.Do(req)
|
||||||
|
|
|
||||||
|
|
@ -411,7 +411,7 @@ func TestNormalizeBaseURL(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewProvider(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 {
|
if provider == nil {
|
||||||
t.Fatal("NewProvider() returned nil")
|
t.Fatal("NewProvider() returned nil")
|
||||||
}
|
}
|
||||||
|
|
@ -424,7 +424,7 @@ func TestNewProvider(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetDefaultModel(t *testing.T) {
|
func TestGetDefaultModel(t *testing.T) {
|
||||||
provider := NewProvider("test-key", "")
|
provider := NewProvider("test-key", "", "")
|
||||||
got := provider.GetDefaultModel()
|
got := provider.GetDefaultModel()
|
||||||
expected := "claude-sonnet-4.6"
|
expected := "claude-sonnet-4.6"
|
||||||
if got != expected {
|
if got != expected {
|
||||||
|
|
@ -743,7 +743,7 @@ func TestProviderChatErrors(t *testing.T) {
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Create provider using constructor to ensure proper initialization
|
// 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)
|
_, err := provider.Chat(context.Background(), tt.messages, nil, "test-model", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ type Provider struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
apiBase string
|
apiBase string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
userAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option configures the Azure Provider.
|
// 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.
|
// 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{
|
p := &Provider{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
apiBase: strings.TrimRight(apiBase, "/"),
|
apiBase: strings.TrimRight(apiBase, "/"),
|
||||||
|
userAgent: userAgent,
|
||||||
httpClient: common.NewHTTPClient(proxy),
|
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.
|
// 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(
|
return NewProvider(
|
||||||
apiKey, apiBase, proxy,
|
apiKey, apiBase, proxy, userAgent,
|
||||||
WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -141,6 +150,9 @@ func (p *Provider) Chat(
|
||||||
if p.apiKey != "" {
|
if p.apiKey != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+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)
|
resp, err := p.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ func TestProviderChat_AzureURLConstruction(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
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)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-gpt5-deployment", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -69,7 +69,7 @@ func TestProviderChat_AzureAuthHeader(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
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)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -92,7 +92,7 @@ func TestProviderChat_AzureRequestBodyContainsModel(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
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)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-deployment", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -112,7 +112,7 @@ func TestProviderChat_AzureUsesMaxOutputTokens(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
_, err := p.Chat(
|
_, err := p.Chat(
|
||||||
t.Context(),
|
t.Context(),
|
||||||
[]Message{{Role: "user", Content: "hi"}},
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
|
@ -144,7 +144,7 @@ func TestProviderChat_AzureStoreIsFalse(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
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)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -161,7 +161,7 @@ func TestProviderChat_AzureHTTPError(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
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)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
|
|
@ -176,7 +176,7 @@ func TestProviderChat_AzureRateLimitError(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
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)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for 429, got nil")
|
t.Fatal("expected error for 429, got nil")
|
||||||
|
|
@ -194,7 +194,7 @@ func TestProviderChat_AzureServerError(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
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)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for 500, got nil")
|
t.Fatal("expected error for 500, got nil")
|
||||||
|
|
@ -229,7 +229,7 @@ func TestProviderChat_AzureParseTextOutput(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
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)
|
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -270,7 +270,7 @@ func TestProviderChat_AzureParseToolCalls(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
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)
|
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "weather?"}}, nil, "deployment", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -287,7 +287,7 @@ func TestProviderChat_AzureParseToolCalls(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProvider_AzureEmptyAPIBase(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)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for empty API base")
|
t.Fatal("expected error for empty API base")
|
||||||
|
|
@ -295,21 +295,21 @@ func TestProvider_AzureEmptyAPIBase(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProvider_AzureRequestTimeoutDefault(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 {
|
if p.httpClient.Timeout != defaultRequestTimeout {
|
||||||
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
|
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProvider_AzureRequestTimeoutOverride(t *testing.T) {
|
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 {
|
if p.httpClient.Timeout != 300*time.Second {
|
||||||
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second)
|
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProvider_AzureNewProviderWithTimeout(t *testing.T) {
|
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 {
|
if p.httpClient.Timeout != 180*time.Second {
|
||||||
t.Errorf("timeout = %v, want %v", 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
|
// 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",
|
_, 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
|
// 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)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment", nil)
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,11 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
|
|
||||||
protocol, modelID := ExtractProtocol(cfg.Model)
|
protocol, modelID := ExtractProtocol(cfg.Model)
|
||||||
|
|
||||||
|
userAgent := cfg.UserAgent
|
||||||
|
if userAgent == "" {
|
||||||
|
userAgent = fmt.Sprintf("PicoClaw/%s", config.Version)
|
||||||
|
}
|
||||||
|
|
||||||
switch protocol {
|
switch protocol {
|
||||||
case "openai":
|
case "openai":
|
||||||
// OpenAI with OAuth/token auth (Codex-style)
|
// OpenAI with OAuth/token auth (Codex-style)
|
||||||
|
|
@ -152,6 +157,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
apiBase,
|
apiBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
cfg.ExtraBody,
|
cfg.ExtraBody,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
@ -171,6 +177,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
cfg.APIKey(),
|
cfg.APIKey(),
|
||||||
cfg.APIBase,
|
cfg.APIBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
|
|
@ -228,6 +235,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
apiBase,
|
apiBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
cfg.ExtraBody,
|
cfg.ExtraBody,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
@ -253,6 +261,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
apiBase,
|
apiBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
extraBody,
|
extraBody,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
@ -279,6 +288,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
apiBase,
|
apiBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
cfg.ExtraBody,
|
cfg.ExtraBody,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
@ -295,6 +305,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
return anthropicmessages.NewProviderWithTimeout(
|
return anthropicmessages.NewProviderWithTimeout(
|
||||||
cfg.APIKey(),
|
cfg.APIKey(),
|
||||||
apiBase,
|
apiBase,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
|
|
@ -310,6 +321,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
return anthropicmessages.NewProviderWithTimeout(
|
return anthropicmessages.NewProviderWithTimeout(
|
||||||
cfg.APIKey(),
|
cfg.APIKey(),
|
||||||
apiBase,
|
apiBase,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestCreateProviderFromConfig_Bedrock(t *testing.T) {
|
||||||
// Set dummy AWS env vars to make test deterministic
|
// Set dummy AWS env vars to make test deterministic
|
||||||
t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
|
t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,11 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField 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(
|
func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
apiKey, apiBase, proxy, maxTokensField string,
|
apiKey, apiBase, proxy, maxTokensField, userAgent string,
|
||||||
requestTimeoutSeconds int,
|
requestTimeoutSeconds int,
|
||||||
extraBody map[string]any,
|
extraBody map[string]any,
|
||||||
) *HTTPProvider {
|
) *HTTPProvider {
|
||||||
|
|
@ -40,6 +40,7 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
openai_compat.WithMaxTokensField(maxTokensField),
|
openai_compat.WithMaxTokensField(maxTokensField),
|
||||||
openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
||||||
openai_compat.WithExtraBody(extraBody),
|
openai_compat.WithExtraBody(extraBody),
|
||||||
|
openai_compat.WithUserAgent(userAgent),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ type Provider struct {
|
||||||
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
|
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
extraBody map[string]any // Additional fields to inject into request body
|
extraBody map[string]any // Additional fields to inject into request body
|
||||||
|
userAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Option func(*Provider)
|
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 {
|
func WithRequestTimeout(timeout time.Duration) Option {
|
||||||
return func(p *Provider) {
|
return func(p *Provider) {
|
||||||
if timeout > 0 {
|
if timeout > 0 {
|
||||||
|
|
@ -198,6 +205,9 @@ func (p *Provider) Chat(
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if p.userAgent != "" {
|
||||||
|
req.Header.Set("User-Agent", p.userAgent)
|
||||||
|
}
|
||||||
if p.apiKey != "" {
|
if p.apiKey != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,22 @@
|
||||||
package tools
|
package tools
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"math"
|
"math"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/fileutil"
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
|
@ -20,7 +24,11 @@ import (
|
||||||
|
|
||||||
const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow
|
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 == "" {
|
if workspace == "" {
|
||||||
return path, fmt.Errorf("workspace is not defined")
|
return path, fmt.Errorf("workspace is not defined")
|
||||||
}
|
}
|
||||||
|
|
@ -253,6 +261,11 @@ type ReadFileTool struct {
|
||||||
maxSize int64
|
maxSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReadFileLinesTool struct {
|
||||||
|
fs fileSystem
|
||||||
|
maxSize int64
|
||||||
|
}
|
||||||
|
|
||||||
func NewReadFileTool(
|
func NewReadFileTool(
|
||||||
workspace string,
|
workspace string,
|
||||||
restrict bool,
|
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 {
|
func (t *ReadFileTool) Name() string {
|
||||||
return "read_file"
|
return "read_file"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *ReadFileLinesTool) Name() string {
|
||||||
|
return "read_file"
|
||||||
|
}
|
||||||
|
|
||||||
func (t *ReadFileTool) Description() string {
|
func (t *ReadFileTool) Description() string {
|
||||||
return "Read the contents of a file. Supports pagination via `offset` and `length`."
|
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 {
|
func (t *ReadFileTool) Parameters() map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"type": "object",
|
"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 {
|
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
|
||||||
path, ok := args["path"].(string)
|
path, ok := args["path"].(string)
|
||||||
if !ok {
|
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))
|
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
|
// getInt64Arg extracts an integer argument from the args map, returning the
|
||||||
// provided default if the key is absent.
|
// provided default if the key is absent.
|
||||||
func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) {
|
func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) {
|
||||||
|
|
@ -483,7 +853,11 @@ type WriteFileTool struct {
|
||||||
fs fileSystem
|
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
|
var patterns []*regexp.Regexp
|
||||||
if len(allowPaths) > 0 {
|
if len(allowPaths) > 0 {
|
||||||
patterns = allowPaths[0]
|
patterns = allowPaths[0]
|
||||||
|
|
@ -536,7 +910,9 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR
|
||||||
|
|
||||||
if !overwrite {
|
if !overwrite {
|
||||||
if _, err := t.fs.Open(path); err == nil {
|
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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
|
||||||
testFile := filepath.Join(tmpDir, "test.txt")
|
testFile := filepath.Join(tmpDir, "test.txt")
|
||||||
os.WriteFile(testFile, []byte("test content"), 0o644)
|
os.WriteFile(testFile, []byte("test content"), 0o644)
|
||||||
|
|
||||||
tool := NewReadFileTool("", false, MaxReadFileSize)
|
tool := NewReadFileBytesTool("", false, MaxReadFileSize)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
"path": testFile,
|
"path": testFile,
|
||||||
|
|
@ -45,7 +45,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) {
|
||||||
|
|
||||||
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
|
// TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file
|
||||||
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
|
func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
|
||||||
tool := NewReadFileTool("", false, MaxReadFileSize)
|
tool := NewReadFileBytesTool("", false, MaxReadFileSize)
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
"path": "/nonexistent_file_12345.txt",
|
"path": "/nonexistent_file_12345.txt",
|
||||||
|
|
@ -59,8 +59,13 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should contain error message
|
// Should contain error message
|
||||||
if !strings.Contains(result.ForLLM, "failed to open file") && !strings.Contains(result.ForUser, "failed to read") {
|
if !strings.Contains(result.ForLLM, "failed to open file") &&
|
||||||
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
|
!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
|
// 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)
|
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",
|
"content": "replaced in sandbox",
|
||||||
"overwrite": true,
|
"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))
|
data, err := os.ReadFile(filepath.Join(workspace, testFile))
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
@ -325,7 +336,8 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should list files and directories
|
// 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)
|
t.Errorf("Expected files in listing, got: %s", result.ForLLM)
|
||||||
}
|
}
|
||||||
if !strings.Contains(result.ForLLM, "subdir") {
|
if !strings.Contains(result.ForLLM, "subdir") {
|
||||||
|
|
@ -349,8 +361,13 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should contain error message
|
// Should contain error message
|
||||||
if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") {
|
if !strings.Contains(result.ForLLM, "failed to read") &&
|
||||||
t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser)
|
!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
|
// os.Root might return different errors depending on platform/implementation
|
||||||
// but it definitely should error.
|
// but it definitely should error.
|
||||||
// Our wrapper returns "access denied or file not found"
|
// 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") {
|
!strings.Contains(result.ForLLM, "no such file") {
|
||||||
t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
|
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)
|
// 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
|
// 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:
|
// 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))}
|
patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))}
|
||||||
tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns)
|
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 {
|
if !result.IsError {
|
||||||
t.Fatalf("expected symlink escape from allowed dir to be blocked, got: %s", result.ForLLM)
|
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()
|
tmpDir := t.TempDir()
|
||||||
testFile := filepath.Join(tmpDir, "pagination_test.txt")
|
testFile := filepath.Join(tmpDir, "pagination_test.txt")
|
||||||
|
|
||||||
// Create a test file with exactly 26 bytes of content
|
|
||||||
fullContent := "abcdefghijklmnopqrstuvwxyz"
|
fullContent := "abcdefghijklmnopqrstuvwxyz"
|
||||||
err := os.WriteFile(testFile, []byte(fullContent), 0o644)
|
err := os.WriteFile(testFile, []byte(fullContent), 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -748,15 +778,12 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
|
||||||
t.Fatalf("Chunk 1 failed: %s", result1.ForLLM)
|
t.Fatalf("Chunk 1 failed: %s", result1.ForLLM)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expect the first 10 characters
|
|
||||||
if !strings.Contains(result1.ForLLM, "abcdefghij") {
|
if !strings.Contains(result1.ForLLM, "abcdefghij") {
|
||||||
t.Errorf("Chunk 1 should contain 'abcdefghij', got: %s", result1.ForLLM)
|
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") {
|
if !strings.Contains(result1.ForLLM, "[TRUNCATED") {
|
||||||
t.Errorf("Chunk 1 header should indicate truncation, got: %s", result1.ForLLM)
|
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") {
|
if !strings.Contains(result1.ForLLM, "offset=10") {
|
||||||
t.Errorf("Chunk 1 header should suggest next offset=10, got: %s", result1.ForLLM)
|
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)
|
t.Fatalf("Chunk 2 failed: %s", result2.ForLLM)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expect the next 10 characters
|
|
||||||
if !strings.Contains(result2.ForLLM, "klmnopqrst") {
|
if !strings.Contains(result2.ForLLM, "klmnopqrst") {
|
||||||
t.Errorf("Chunk 2 should contain 'klmnopqrst', got: %s", result2.ForLLM)
|
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") {
|
if !strings.Contains(result2.ForLLM, "offset=20") {
|
||||||
t.Errorf("Chunk 2 header should suggest next offset=20, got: %s", result2.ForLLM)
|
t.Errorf("Chunk 2 header should suggest next offset=20, got: %s", result2.ForLLM)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Read the final chunk (remaining 6 bytes) ---
|
// 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{
|
args3 := map[string]any{
|
||||||
"path": testFile,
|
"path": testFile,
|
||||||
"offset": 20,
|
"offset": 20,
|
||||||
|
|
@ -795,16 +819,12 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
|
||||||
t.Fatalf("Chunk 3 failed: %s", result3.ForLLM)
|
t.Fatalf("Chunk 3 failed: %s", result3.ForLLM)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expect the last 6 characters
|
|
||||||
if !strings.Contains(result3.ForLLM, "uvwxyz") {
|
if !strings.Contains(result3.ForLLM, "uvwxyz") {
|
||||||
t.Errorf("Chunk 3 should contain 'uvwxyz', got: %s", result3.ForLLM)
|
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") {
|
if !strings.Contains(result3.ForLLM, "[END OF FILE") {
|
||||||
t.Errorf("Chunk 3 header should indicate end of file, got: %s", result3.ForLLM)
|
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") {
|
if strings.Contains(result3.ForLLM, "[TRUNCATED") {
|
||||||
t.Errorf("Chunk 3 header should NOT indicate truncation, got: %s", result3.ForLLM)
|
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()
|
tmpDir := t.TempDir()
|
||||||
testFile := filepath.Join(tmpDir, "short.txt")
|
testFile := filepath.Join(tmpDir, "short.txt")
|
||||||
|
|
||||||
// create a file of only 5 bytes
|
|
||||||
err := os.WriteFile(testFile, []byte("12345"), 0o644)
|
err := os.WriteFile(testFile, []byte("12345"), 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Failed to write test file: %v", err)
|
t.Fatalf("Failed to write test file: %v", err)
|
||||||
|
|
@ -827,19 +846,393 @@ func TestReadFileTool_OffsetBeyondEOF(t *testing.T) {
|
||||||
|
|
||||||
args := map[string]any{
|
args := map[string]any{
|
||||||
"path": testFile,
|
"path": testFile,
|
||||||
"offset": int64(100), // Offset beyond the end of the file
|
"offset": int64(100),
|
||||||
}
|
}
|
||||||
|
|
||||||
result := tool.Execute(ctx, args)
|
result := tool.Execute(ctx, args)
|
||||||
|
|
||||||
// It should not be classified as a tool execution error
|
|
||||||
if result.IsError {
|
if result.IsError {
|
||||||
t.Errorf("A mistake was not expected, obtained IsError=true: %s", result.ForLLM)
|
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]"
|
expectedMsg := "[END OF FILE - no content at this offset]"
|
||||||
if result.ForLLM != expectedMsg {
|
if result.ForLLM != expectedMsg {
|
||||||
t.Errorf("The message %q was expected, obtained: %q", expectedMsg, result.ForLLM)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue