From 2c446e1e07f44bdcc3b772cc119b2e2481b00e7d Mon Sep 17 00:00:00 2001 From: Cytown Date: Thu, 2 Apr 2026 11:44:13 +0800 Subject: [PATCH 1/5] feat: add userAgent config for ModelConfig (#2242) * feat: add userAgent config for ModelConfig * update docs for ModelConfig.userAgent * make defaut userAgent to PicoClaw and add test case --- docs/fr/providers.md | 19 ++++ docs/ja/providers.md | 19 ++++ docs/providers.md | 19 ++++ docs/pt-br/providers.md | 19 ++++ docs/vi/providers.md | 19 ++++ docs/zh/providers.md | 19 ++++ pkg/config/config.go | 2 + pkg/providers/anthropic_messages/provider.go | 15 ++- .../anthropic_messages/provider_test.go | 6 +- pkg/providers/azure/provider.go | 18 +++- pkg/providers/azure/provider_test.go | 32 +++--- pkg/providers/factory_provider.go | 12 +++ pkg/providers/factory_provider_test.go | 101 ++++++++++++++++++ pkg/providers/http_provider.go | 5 +- pkg/providers/openai_compat/provider.go | 10 ++ 15 files changed, 286 insertions(+), 29 deletions(-) diff --git a/docs/fr/providers.md b/docs/fr/providers.md index d0da81897..3305ec5ee 100644 --- a/docs/fr/providers.md +++ b/docs/fr/providers.md @@ -99,6 +99,24 @@ Cette conception permet également le **support multi-agents** avec une sélecti } ``` +#### Champs d'entrée `model_list` + +| Champ | Type | Requis | Description | +|-------|------|--------|-------------| +| `model_name` | string | Oui | Nom unique pour référencer ce modèle dans la config agent | +| `model` | string | Oui | Identifiant fournisseur/modèle (ex : `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) | +| `api_keys` | string[] | Oui* | Clé(s) API pour l'authentification. Plusieurs clés permettent la rotation par requête. Non requis pour les fournisseurs locaux (Ollama, LM Studio, VLLM) | +| `api_base` | string | Non | Remplace l'URL de base API par défaut | +| `proxy` | string | Non | URL du proxy HTTP pour cette entrée de modèle | +| `user_agent` | string | Non | En-tête `User-Agent` personnalisé pour les requêtes API (supporté par les providers OpenAI-compatible, Anthropic et Azure) | +| `request_timeout` | int | Non | Délai d'expiration de la requête en secondes (la valeur par défaut varie selon le provider) | +| `max_tokens_field` | string | Non | Remplace le nom du champ max tokens dans le corps de la requête (ex : `max_completion_tokens` pour les modèles o1) | +| `thinking_level` | string | Non | Niveau de pensée étendue : `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` | +| `extra_body` | object | Non | Champs supplémentaires à injecter dans chaque corps de requête | +| `rpm` | int | Non | Limite de requêtes par minute | +| `fallbacks` | string[] | Non | Noms des modèles de secours pour le basculement automatique | +| `enabled` | bool | Non | Activer ou désactiver cette entrée de modèle (par défaut : `true`) | + #### Exemples par Vendor **OpenAI** @@ -190,6 +208,7 @@ Pour l'accès direct à l'API Anthropic ou les endpoints personnalisés qui ne p "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", "request_timeout": 300 } ``` diff --git a/docs/ja/providers.md b/docs/ja/providers.md index e29c113f3..878530966 100644 --- a/docs/ja/providers.md +++ b/docs/ja/providers.md @@ -99,6 +99,24 @@ } ``` +#### `model_list` エントリフィールド + +| フィールド | 型 | 必須 | 説明 | +|-----------|------|------|------| +| `model_name` | string | はい | agent 設定でこのモデルを参照するための一意の名前 | +| `model` | string | はい | ベンダー/モデル識別子(例:`openai/gpt-5.4`、`azure/gpt-5.4`、`anthropic/claude-sonnet-4.6`) | +| `api_keys` | string[] | はい* | 認証キー。複数キーでリクエストごとのローテーションが可能。ローカル provider(Ollama、LM Studio、VLLM)には不要 | +| `api_base` | string | いいえ | デフォルトの API エンドポイント URL を上書き | +| `proxy` | string | いいえ | このモデルエントリの HTTP プロキシ URL | +| `user_agent` | string | いいえ | カスタム `User-Agent` リクエストヘッダー(OpenAI 互換、Anthropic、Azure provider で対応) | +| `request_timeout` | int | いいえ | リクエストタイムアウト(秒)。デフォルト値は provider により異なる | +| `max_tokens_field` | string | いいえ | リクエストボディの max tokens フィールド名を上書き(例:o1 モデルでは `max_completion_tokens`) | +| `thinking_level` | string | いいえ | 拡張思考レベル:`off`、`low`、`medium`、`high`、`xhigh`、`adaptive` | +| `extra_body` | object | いいえ | 各リクエストボディに注入する追加フィールド | +| `rpm` | int | いいえ | 1 分あたりのリクエストレート制限 | +| `fallbacks` | string[] | いいえ | 自動フェイルオーバーのフォールバックモデル名 | +| `enabled` | bool | いいえ | このモデルエントリを有効にするかどうか(デフォルト:`true`) | + #### ベンダー別設定例 **OpenAI** @@ -201,6 +219,7 @@ Anthropic API への直接アクセスや、Anthropic のネイティブメッ "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", "request_timeout": 300 } ``` diff --git a/docs/providers.md b/docs/providers.md index b0dfa0bc8..9bb95446c 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -108,6 +108,24 @@ This design also enables **multi-agent support** with flexible provider selectio } ``` +#### `model_list` Entry Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `model_name` | string | Yes | Unique name used to reference this model in agent config | +| `model` | string | Yes | Vendor/model identifier (e.g., `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) | +| `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) | +| `api_base` | string | No | Override the default API endpoint URL | +| `proxy` | string | No | HTTP proxy URL for this model entry | +| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Anthropic, and Azure providers) | +| `request_timeout` | int | No | Request timeout in seconds (default varies by provider) | +| `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) | +| `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` | +| `extra_body` | object | No | Additional fields to inject into every request body | +| `rpm` | int | No | Per-minute request rate limit | +| `fallbacks` | string[] | No | Fallback model names for automatic failover | +| `enabled` | bool | No | Whether this model entry is active (default: `true`) | + #### Voice Transcription You can configure a dedicated model for audio transcription with `voice.model_name`. This lets you reuse existing multimodal providers that support audio input instead of relying only on Groq. @@ -249,6 +267,7 @@ PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", "request_timeout": 300 } ``` diff --git a/docs/pt-br/providers.md b/docs/pt-br/providers.md index c7c6305e2..103490dc7 100644 --- a/docs/pt-br/providers.md +++ b/docs/pt-br/providers.md @@ -99,6 +99,24 @@ Este design também permite **suporte multi-agente** com seleção flexível de } ``` +#### Campos de entrada `model_list` + +| Campo | Tipo | Obrigatório | Descrição | +|-------|------|-------------|-----------| +| `model_name` | string | Sim | Nome único para referenciar este modelo na config do agent | +| `model` | string | Sim | Identificador fornecedor/modelo (ex: `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) | +| `api_keys` | string[] | Sim* | Chave(s) API para autenticação. Múltiplas chaves permitem rotação por requisição. Não necessário para providers locais (Ollama, LM Studio, VLLM) | +| `api_base` | string | Não | Substitui a URL base da API padrão | +| `proxy` | string | Não | URL do proxy HTTP para esta entrada de modelo | +| `user_agent` | string | Não | Cabeçalho `User-Agent` personalizado enviado com requisições API (suportado por providers OpenAI-compatible, Anthropic e Azure) | +| `request_timeout` | int | Não | Timeout de requisição em segundos (o padrão varia por provider) | +| `max_tokens_field` | string | Não | Substitui o nome do campo max tokens no corpo da requisição (ex: `max_completion_tokens` para modelos o1) | +| `thinking_level` | string | Não | Nível de pensamento estendido: `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` | +| `extra_body` | object | Não | Campos adicionais para injetar em cada corpo de requisição | +| `rpm` | int | Não | Limite de requisições por minuto | +| `fallbacks` | string[] | Não | Nomes dos modelos de fallback para failover automático | +| `enabled` | bool | Não | Ativar ou desativar esta entrada de modelo (padrão: `true`) | + #### Exemplos por Vendor **OpenAI** @@ -190,6 +208,7 @@ Para acesso direto à API Anthropic ou endpoints personalizados que suportam ape "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", "request_timeout": 300 } ``` diff --git a/docs/vi/providers.md b/docs/vi/providers.md index ffd992645..46c9de663 100644 --- a/docs/vi/providers.md +++ b/docs/vi/providers.md @@ -99,6 +99,24 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr } ``` +#### Các trường entry `model_list` + +| Trường | Kiểu | Bắt buộc | Mô tả | +|--------|------|----------|------| +| `model_name` | string | Có | Tên duy nhất để tham chiếu model này trong cấu hình agent | +| `model` | string | Có | Định danh nhà cung cấp/model (ví dụ: `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) | +| `api_keys` | string[] | Có* | Khóa API xác thực. Nhiều khóa cho phép xoay vòng theo yêu cầu. Không cần thiết cho provider nội bộ (Ollama, LM Studio, VLLM) | +| `api_base` | string | Không | Ghi đè URL endpoint API mặc định | +| `proxy` | string | Không | URL proxy HTTP cho entry model này | +| `user_agent` | string | Không | Header `User-Agent` tùy chỉnh gửi với yêu cầu API (được hỗ trợ bởi provider OpenAI-compatible, Anthropic và Azure) | +| `request_timeout` | int | Không | Timeout yêu cầu tính bằng giây (mặc định khác nhau tùy provider) | +| `max_tokens_field` | string | Không | Ghi đè tên trường max tokens trong request body (ví dụ: `max_completion_tokens` cho model o1) | +| `thinking_level` | string | Không | Mức độ tư duy mở rộng: `off`, `low`, `medium`, `high`, `xhigh` hoặc `adaptive` | +| `extra_body` | object | Không | Các trường bổ sung để chèn vào mỗi request body | +| `rpm` | int | Không | Giới hạn tốc độ yêu cầu mỗi phút | +| `fallbacks` | string[] | Không | Tên model dự phòng cho failover tự động | +| `enabled` | bool | Không | Kích hoạt hay vô hiệu hóa entry model này (mặc định: `true`) | + #### Ví Dụ Theo Vendor **OpenAI** @@ -190,6 +208,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", "request_timeout": 300 } ``` diff --git a/docs/zh/providers.md b/docs/zh/providers.md index 43c4f26db..6048b929f 100644 --- a/docs/zh/providers.md +++ b/docs/zh/providers.md @@ -104,6 +104,24 @@ } ``` +#### `model_list` 条目字段 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `model_name` | string | 是 | 在 agent 配置中引用此模型的唯一名称 | +| `model` | string | 是 | 厂商/模型标识符(如 `openai/gpt-5.4`、`azure/gpt-5.4`、`anthropic/claude-sonnet-4.6`) | +| `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 provider(Ollama、LM Studio、VLLM)不需要 | +| `api_base` | string | 否 | 覆盖默认的 API 端点 URL | +| `proxy` | string | 否 | 此模型条目的 HTTP 代理 URL | +| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Anthropic 和 Azure provider) | +| `request_timeout` | int | 否 | 请求超时时间(秒),默认值因 provider 而异 | +| `max_tokens_field` | string | 否 | 覆盖请求体中 max tokens 的字段名(如 o1 模型使用 `max_completion_tokens`) | +| `thinking_level` | string | 否 | 扩展思考级别:`off`、`low`、`medium`、`high`、`xhigh` 或 `adaptive` | +| `extra_body` | object | 否 | 注入到每个请求体中的额外字段 | +| `rpm` | int | 否 | 每分钟请求速率限制 | +| `fallbacks` | string[] | 否 | 自动故障转移的备用模型名称 | +| `enabled` | bool | 否 | 是否启用此模型条目(默认:`true`) | + #### 语音转录 你可以通过 `voice.model_name` 为语音转录指定一个专用模型。这样可以直接复用已经配置好的、支持音频输入的多模态 provider,而不必只依赖 Groq。 @@ -234,6 +252,7 @@ PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首 "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", "request_timeout": 300 } ``` diff --git a/pkg/config/config.go b/pkg/config/config.go index a35689bc1..fcedf45b9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -600,6 +600,8 @@ type ModelConfig struct { // existing configs, the field is inferred during load: models with API keys // or the reserved "local-model" name are auto-enabled. Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + // UserAgent is the user agent string to use for HTTP requests. + UserAgent string `json:"user_agent,omitempty" yaml:"-"` // isVirtual marks this model as a virtual model generated from multi-key expansion. // Virtual models should not be persisted to config files. diff --git a/pkg/providers/anthropic_messages/provider.go b/pkg/providers/anthropic_messages/provider.go index 6a1c473dd..1e865b709 100644 --- a/pkg/providers/anthropic_messages/provider.go +++ b/pkg/providers/anthropic_messages/provider.go @@ -41,15 +41,16 @@ type Provider struct { apiKey string apiBase string httpClient *http.Client + userAgent string } // NewProvider creates a new Anthropic Messages API provider. -func NewProvider(apiKey, apiBase string) *Provider { - return NewProviderWithTimeout(apiKey, apiBase, 0) +func NewProvider(apiKey, apiBase, userAgent string) *Provider { + return NewProviderWithTimeout(apiKey, apiBase, userAgent, 0) } // NewProviderWithTimeout creates a provider with custom request timeout. -func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provider { +func NewProviderWithTimeout(apiKey, apiBase, userAgent string, timeoutSeconds int) *Provider { baseURL := normalizeBaseURL(apiBase) timeout := defaultRequestTimeout if timeoutSeconds > 0 { @@ -57,8 +58,9 @@ func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provide } return &Provider{ - apiKey: apiKey, - apiBase: baseURL, + apiKey: apiKey, + apiBase: baseURL, + userAgent: userAgent, httpClient: &http.Client{ Timeout: timeout, }, @@ -105,6 +107,9 @@ func (p *Provider) Chat( req.Header.Set("Content-Type", "application/json") req.Header.Set("X-API-Key", p.apiKey) //nolint:canonicalheader // Anthropic API requires exact header name req.Header.Set("Anthropic-Version", defaultAPIVersion) + if p.userAgent != "" { + req.Header.Set("User-Agent", p.userAgent) + } // Execute request resp, err := p.httpClient.Do(req) diff --git a/pkg/providers/anthropic_messages/provider_test.go b/pkg/providers/anthropic_messages/provider_test.go index 39bc48117..ba9d24b66 100644 --- a/pkg/providers/anthropic_messages/provider_test.go +++ b/pkg/providers/anthropic_messages/provider_test.go @@ -411,7 +411,7 @@ func TestNormalizeBaseURL(t *testing.T) { } func TestNewProvider(t *testing.T) { - provider := NewProvider("test-key", "https://api.example.com") + provider := NewProvider("test-key", "https://api.example.com", "") if provider == nil { t.Fatal("NewProvider() returned nil") } @@ -424,7 +424,7 @@ func TestNewProvider(t *testing.T) { } func TestGetDefaultModel(t *testing.T) { - provider := NewProvider("test-key", "") + provider := NewProvider("test-key", "", "") got := provider.GetDefaultModel() expected := "claude-sonnet-4.6" if got != expected { @@ -743,7 +743,7 @@ func TestProviderChatErrors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create provider using constructor to ensure proper initialization - provider := NewProvider(tt.apiKey, "https://api.example.com") + provider := NewProvider(tt.apiKey, "https://api.example.com", "") _, err := provider.Chat(context.Background(), tt.messages, nil, "test-model", nil) if err == nil { diff --git a/pkg/providers/azure/provider.go b/pkg/providers/azure/provider.go index 429b26798..7de703248 100644 --- a/pkg/providers/azure/provider.go +++ b/pkg/providers/azure/provider.go @@ -36,6 +36,7 @@ type Provider struct { apiKey string apiBase string httpClient *http.Client + userAgent string } // Option configures the Azure Provider. @@ -50,11 +51,19 @@ func WithRequestTimeout(timeout time.Duration) Option { } } +// WithUserAgent sets the User-Agent header for requests. +func WithUserAgent(userAgent string) Option { + return func(p *Provider) { + p.userAgent = userAgent + } +} + // NewProvider creates a new Azure OpenAI provider. -func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider { +func NewProvider(apiKey, apiBase, proxy, userAgent string, opts ...Option) *Provider { p := &Provider{ apiKey: apiKey, apiBase: strings.TrimRight(apiBase, "/"), + userAgent: userAgent, httpClient: common.NewHTTPClient(proxy), } @@ -68,9 +77,9 @@ func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider { } // NewProviderWithTimeout creates a new Azure OpenAI provider with a custom request timeout in seconds. -func NewProviderWithTimeout(apiKey, apiBase, proxy string, requestTimeoutSeconds int) *Provider { +func NewProviderWithTimeout(apiKey, apiBase, proxy, userAgent string, requestTimeoutSeconds int) *Provider { return NewProvider( - apiKey, apiBase, proxy, + apiKey, apiBase, proxy, userAgent, WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), ) } @@ -141,6 +150,9 @@ func (p *Provider) Chat( if p.apiKey != "" { req.Header.Set("Authorization", "Bearer "+p.apiKey) } + if p.userAgent != "" { + req.Header.Set("User-Agent", p.userAgent) + } resp, err := p.httpClient.Do(req) if err != nil { diff --git a/pkg/providers/azure/provider_test.go b/pkg/providers/azure/provider_test.go index b3752ea50..816ae97dc 100644 --- a/pkg/providers/azure/provider_test.go +++ b/pkg/providers/azure/provider_test.go @@ -46,7 +46,7 @@ func TestProviderChat_AzureURLConstruction(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-gpt5-deployment", nil) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -69,7 +69,7 @@ func TestProviderChat_AzureAuthHeader(t *testing.T) { })) defer server.Close() - p := NewProvider("test-azure-key", server.URL, "") + p := NewProvider("test-azure-key", server.URL, "", "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -92,7 +92,7 @@ func TestProviderChat_AzureRequestBodyContainsModel(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-deployment", nil) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -112,7 +112,7 @@ func TestProviderChat_AzureUsesMaxOutputTokens(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") _, err := p.Chat( t.Context(), []Message{{Role: "user", Content: "hi"}}, @@ -144,7 +144,7 @@ func TestProviderChat_AzureStoreIsFalse(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -161,7 +161,7 @@ func TestProviderChat_AzureHTTPError(t *testing.T) { })) defer server.Close() - p := NewProvider("bad-key", server.URL, "") + p := NewProvider("bad-key", server.URL, "", "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) if err == nil { t.Fatal("expected error, got nil") @@ -176,7 +176,7 @@ func TestProviderChat_AzureRateLimitError(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) if err == nil { t.Fatal("expected error for 429, got nil") @@ -194,7 +194,7 @@ func TestProviderChat_AzureServerError(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) if err == nil { t.Fatal("expected error for 500, got nil") @@ -229,7 +229,7 @@ func TestProviderChat_AzureParseTextOutput(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -270,7 +270,7 @@ func TestProviderChat_AzureParseToolCalls(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "weather?"}}, nil, "deployment", nil) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -287,7 +287,7 @@ func TestProviderChat_AzureParseToolCalls(t *testing.T) { } func TestProvider_AzureEmptyAPIBase(t *testing.T) { - p := NewProvider("test-key", "", "") + p := NewProvider("test-key", "", "", "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) if err == nil { t.Fatal("expected error for empty API base") @@ -295,21 +295,21 @@ func TestProvider_AzureEmptyAPIBase(t *testing.T) { } func TestProvider_AzureRequestTimeoutDefault(t *testing.T) { - p := NewProvider("test-key", "https://example.com", "") + p := NewProvider("test-key", "https://example.com", "", "") if p.httpClient.Timeout != defaultRequestTimeout { t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout) } } func TestProvider_AzureRequestTimeoutOverride(t *testing.T) { - p := NewProvider("test-key", "https://example.com", "", WithRequestTimeout(300*time.Second)) + p := NewProvider("test-key", "https://example.com", "", "", WithRequestTimeout(300*time.Second)) if p.httpClient.Timeout != 300*time.Second { t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second) } } func TestProvider_AzureNewProviderWithTimeout(t *testing.T) { - p := NewProviderWithTimeout("test-key", "https://example.com", "", 180) + p := NewProviderWithTimeout("test-key", "https://example.com", "", "", 180) if p.httpClient.Timeout != 180*time.Second { t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 180*time.Second) } @@ -343,7 +343,7 @@ func TestProviderChat_AzureNativeWebSearchInjection(t *testing.T) { }, } - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") // With native_search=true: user-defined web_search should be replaced by built-in _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment", @@ -393,7 +393,7 @@ func TestProviderChat_AzureNoNativeWebSearch(t *testing.T) { }, } - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") // Without native_search: user-defined web_search should be kept as-is _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment", nil) diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index fb5191bf8..ab7277fae 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -129,6 +129,11 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err protocol, modelID := ExtractProtocol(cfg.Model) + userAgent := cfg.UserAgent + if userAgent == "" { + userAgent = fmt.Sprintf("PicoClaw/%s", config.Version) + } + switch protocol { case "openai": // OpenAI with OAuth/token auth (Codex-style) @@ -152,6 +157,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err apiBase, cfg.Proxy, cfg.MaxTokensField, + userAgent, cfg.RequestTimeout, cfg.ExtraBody, ), modelID, nil @@ -171,6 +177,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.APIKey(), cfg.APIBase, cfg.Proxy, + userAgent, cfg.RequestTimeout, ), modelID, nil @@ -228,6 +235,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err apiBase, cfg.Proxy, cfg.MaxTokensField, + userAgent, cfg.RequestTimeout, cfg.ExtraBody, ), modelID, nil @@ -253,6 +261,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err apiBase, cfg.Proxy, cfg.MaxTokensField, + userAgent, cfg.RequestTimeout, extraBody, ), modelID, nil @@ -279,6 +288,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err apiBase, cfg.Proxy, cfg.MaxTokensField, + userAgent, cfg.RequestTimeout, cfg.ExtraBody, ), modelID, nil @@ -295,6 +305,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err return anthropicmessages.NewProviderWithTimeout( cfg.APIKey(), apiBase, + userAgent, cfg.RequestTimeout, ), modelID, nil @@ -310,6 +321,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err return anthropicmessages.NewProviderWithTimeout( cfg.APIKey(), apiBase, + userAgent, cfg.RequestTimeout, ), modelID, nil diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index e2eafb934..b4f672f7a 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -846,6 +846,107 @@ func TestCreateProviderFromConfig_MinimaxPreservesUserExtraBody(t *testing.T) { } } +// openaiCompatResponse is the JSON response used by OpenAI-compatible providers. +const openaiCompatResponse = `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}` + +// anthropicResponse is the JSON response used by Anthropic providers. +const anthropicResponse = `{"content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","model":"claude-sonnet-4-20250514","usage":{"input_tokens":10,"output_tokens":5}}` + +func TestCreateProviderFromConfig_UserAgent(t *testing.T) { + defaultUA := "PicoClaw/" + config.Version + + tests := []struct { + name string + model string + userAgent string + apiKey string + response string + wantUA string + chatOpts map[string]any + }{ + { + name: "openai default user agent", + model: "openai/gpt-4o", + apiKey: "test-key", + response: openaiCompatResponse, + wantUA: defaultUA, + }, + { + name: "openai custom user agent", + model: "openai/gpt-4o", + apiKey: "test-key", + userAgent: "MyAgent/1.2.3", + response: openaiCompatResponse, + wantUA: "MyAgent/1.2.3", + }, + { + name: "anthropic default user agent", + model: "anthropic/claude-sonnet-4-20250514", + apiKey: "test-key", + response: anthropicResponse, + wantUA: defaultUA, + }, + { + name: "anthropic-messages default user agent", + model: "anthropic-messages/claude-sonnet-4-20250514", + apiKey: "test-key", + response: anthropicResponse, + wantUA: defaultUA, + chatOpts: map[string]any{"max_tokens": 1024}, + }, + { + name: "azure default user agent", + model: "azure/my-deployment", + apiKey: "test-azure-key", + response: openaiCompatResponse, + wantUA: defaultUA, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var receivedUA string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedUA = r.Header.Get("User-Agent") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tt.response)) + })) + defer server.Close() + + cfg := &config.ModelConfig{ + ModelName: "test-ua-" + tt.name, + Model: tt.model, + APIBase: server.URL, + UserAgent: tt.userAgent, + } + cfg.SetAPIKey(tt.apiKey) + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + + _, err = provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + modelID, + tt.chatOpts, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if receivedUA != tt.wantUA { + t.Errorf("User-Agent = %q, want %q", receivedUA, tt.wantUA) + } + }) + } +} + func TestCreateProviderFromConfig_Bedrock(t *testing.T) { // Set dummy AWS env vars to make test deterministic t.Setenv("AWS_ACCESS_KEY_ID", "test-key") diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index f2ff52f1d..dae730536 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -24,11 +24,11 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { } func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider { - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0, nil) + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, "", 0, nil) } func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( - apiKey, apiBase, proxy, maxTokensField string, + apiKey, apiBase, proxy, maxTokensField, userAgent string, requestTimeoutSeconds int, extraBody map[string]any, ) *HTTPProvider { @@ -40,6 +40,7 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( openai_compat.WithMaxTokensField(maxTokensField), openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), openai_compat.WithExtraBody(extraBody), + openai_compat.WithUserAgent(userAgent), ), } } diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 4ff42506f..7cda033ad 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -36,6 +36,7 @@ type Provider struct { maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models) httpClient *http.Client extraBody map[string]any // Additional fields to inject into request body + userAgent string } type Option func(*Provider) @@ -66,6 +67,12 @@ func WithMaxTokensField(maxTokensField string) Option { } } +func WithUserAgent(userAgent string) Option { + return func(p *Provider) { + p.userAgent = userAgent + } +} + func WithRequestTimeout(timeout time.Duration) Option { return func(p *Provider) { if timeout > 0 { @@ -198,6 +205,9 @@ func (p *Provider) Chat( } req.Header.Set("Content-Type", "application/json") + if p.userAgent != "" { + req.Header.Set("User-Agent", p.userAgent) + } if p.apiKey != "" { req.Header.Set("Authorization", "Bearer "+p.apiKey) } From adf78092daeb23bc873b0114a2d81e173de7c79f Mon Sep 17 00:00:00 2001 From: Cytown Date: Thu, 2 Apr 2026 12:02:24 +0800 Subject: [PATCH 2/5] fix typo in create_dmg.yml (#2255) --- .github/workflows/create_dmg.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/create_dmg.yml b/.github/workflows/create_dmg.yml index b47247fc2..d0a820944 100644 --- a/.github/workflows/create_dmg.yml +++ b/.github/workflows/create_dmg.yml @@ -54,9 +54,9 @@ jobs: "dist/picoclaw-${{ matrix.arch }}.dmg" \ "build/PicoClaw Launcher.app" - # 6. 上传文件到 GitHub Artifacts (供你下载) + # 7. 上传文件到 GitHub Artifacts (供你下载) - name: Upload DMG uses: actions/upload-artifact@v4 with: name: macos-dmg-${{ matrix.arch }} - path: dist/*.dmg \ No newline at end of file + path: dist/*.dmg From 257aa0ff573dbcfbf3e88770b5569e4c5f43c836 Mon Sep 17 00:00:00 2001 From: SakoroYou <165740095+Sakurapainting@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:14:47 +0800 Subject: [PATCH 3/5] fix(channels): fail fast when all channel startups fail (#2262) * fix(channels): fail fast when all channel startups fail * fix(channels): preserve startup errors and cover fail-fast semantics --- pkg/channels/manager.go | 41 ++++++++++++- pkg/channels/manager_test.go | 112 ++++++++++++++++++++++++++++++++++- 2 files changed, 150 insertions(+), 3 deletions(-) diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 5fbf35ebf..239448a1c 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -12,6 +12,7 @@ import ( "fmt" "math" "net/http" + "sort" "sync" "time" @@ -513,6 +514,8 @@ func (m *Manager) StartAll(ctx context.Context) error { dispatchCtx, cancel := context.WithCancel(ctx) m.dispatchTask = &asyncTask{cancel: cancel} + failedStarts := make([]error, 0, len(m.channels)) + failedNames := make([]string, 0, len(m.channels)) for name, channel := range m.channels { logger.InfoCF("channels", "Starting channel", map[string]any{ @@ -523,6 +526,8 @@ func (m *Manager) StartAll(ctx context.Context) error { "channel": name, "error": err.Error(), }) + failedStarts = append(failedStarts, fmt.Errorf("channel %s: %w", name, err)) + failedNames = append(failedNames, name) continue } // Lazily create worker only after channel starts successfully @@ -532,6 +537,36 @@ func (m *Manager) StartAll(ctx context.Context) error { go m.runMediaWorker(dispatchCtx, name, w) } + if len(m.channels) > 0 && len(m.workers) == 0 { + if m.dispatchTask != nil { + m.dispatchTask.cancel() + m.dispatchTask = nil + } + + sort.Strings(failedNames) + if len(failedStarts) == 0 { + return fmt.Errorf("failed to start any enabled channels") + } + + logger.ErrorCF("channels", "All enabled channels failed to start", map[string]any{ + "failed": len(failedNames), + "total": len(m.channels), + "failed_channels": failedNames, + }) + + return fmt.Errorf("failed to start any enabled channels: %w", errors.Join(failedStarts...)) + } + + if len(failedNames) > 0 { + sort.Strings(failedNames) + logger.WarnCF("channels", "Some channels failed to start", map[string]any{ + "failed": len(failedNames), + "started": len(m.workers), + "total": len(m.channels), + "failed_channels": failedNames, + }) + } + // Start the dispatcher that reads from the bus and routes to workers go m.dispatchOutbound(dispatchCtx) go m.dispatchOutboundMedia(dispatchCtx) @@ -553,7 +588,11 @@ func (m *Manager) StartAll(ctx context.Context) error { }() } - logger.InfoC("channels", "All channels started") + logger.InfoCF("channels", "Channel startup completed", map[string]any{ + "started": len(m.workers), + "failed": len(failedNames), + "total": len(m.channels), + }) return nil } diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index e76212905..937b32d2c 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -19,6 +19,8 @@ import ( type mockChannel struct { BaseChannel sendFn func(ctx context.Context, msg bus.OutboundMessage) error + startFn func(ctx context.Context) error + stopFn func(ctx context.Context) error sentMessages []bus.OutboundMessage placeholdersSent int editedMessages int @@ -33,8 +35,19 @@ func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri return nil, m.sendFn(ctx, msg) } -func (m *mockChannel) Start(ctx context.Context) error { return nil } -func (m *mockChannel) Stop(ctx context.Context) error { return nil } +func (m *mockChannel) Start(ctx context.Context) error { + if m.startFn != nil { + return m.startFn(ctx) + } + return nil +} + +func (m *mockChannel) Stop(ctx context.Context) error { + if m.stopFn != nil { + return m.stopFn(ctx) + } + return nil +} func (m *mockChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { m.placeholdersSent++ @@ -86,6 +99,101 @@ func newTestManager() *Manager { return &Manager{ channels: make(map[string]Channel), workers: make(map[string]*channelWorker), + bus: bus.NewMessageBus(), + } +} + +func TestStartAll_AllChannelsFail_ReturnsJoinedError(t *testing.T) { + m := newTestManager() + errA := errors.New("channel-a start failed") + errB := errors.New("channel-b start failed") + + m.channels["a"] = &mockChannel{ + startFn: func(_ context.Context) error { return errA }, + } + m.channels["b"] = &mockChannel{ + startFn: func(_ context.Context) error { return errB }, + } + + err := m.StartAll(t.Context()) + if err == nil { + t.Fatal("expected StartAll to fail when all channels fail") + } + if !strings.Contains(err.Error(), "failed to start any enabled channels") { + t.Fatalf("unexpected error: %v", err) + } + if !errors.Is(err, errA) { + t.Fatalf("expected error to wrap errA, got: %v", err) + } + if !errors.Is(err, errB) { + t.Fatalf("expected error to wrap errB, got: %v", err) + } + if len(m.workers) != 0 { + t.Fatalf("expected no workers on full startup failure, got %d", len(m.workers)) + } + if m.dispatchTask != nil { + t.Fatal("expected dispatch task to be cleared on full startup failure") + } +} + +func TestStartAll_PartialFailure_StartsSuccessfulWorkers(t *testing.T) { + m := newTestManager() + errBad := errors.New("bad channel start failed") + processed := make(chan struct{}, 1) + + m.channels["good"] = &mockChannel{ + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + if msg.Channel == "good" { + select { + case processed <- struct{}{}: + default: + } + } + return nil + }, + } + m.channels["bad"] = &mockChannel{ + startFn: func(_ context.Context) error { return errBad }, + } + + err := m.StartAll(t.Context()) + if err != nil { + t.Fatalf("expected StartAll to succeed with partial channel failures, got: %v", err) + } + if len(m.workers) != 1 { + t.Fatalf("expected exactly 1 active worker, got %d", len(m.workers)) + } + if _, ok := m.workers["good"]; !ok { + t.Fatal("expected worker for successful channel 'good'") + } + if _, ok := m.workers["bad"]; ok { + t.Fatal("did not expect worker for failed channel 'bad'") + } + if m.dispatchTask == nil { + t.Fatal("expected dispatch task to run when at least one channel starts") + } + + pubCtx, pubCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer pubCancel() + if err := m.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Channel: "good", + ChatID: "chat-1", + Content: "hello", + }); err != nil { + t.Fatalf("PublishOutbound() error = %v", err) + } + + select { + case <-processed: + // worker processed outbound message as expected + case <-time.After(2 * time.Second): + t.Fatal("expected successful channel worker to process outbound message") + } + + stopCtx, stopCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer stopCancel() + if err := m.StopAll(stopCtx); err != nil { + t.Fatalf("StopAll() error = %v", err) } } From 03b97e412e4c7ce13472718963d0ed227f9452fa Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:23:01 +0800 Subject: [PATCH 4/5] docs: optimize readme for android (#2272) * * update readme.md * update * * update --- README.fr.md | 27 +++++++++++++++++++-------- README.id.md | 27 +++++++++++++++++++-------- README.it.md | 27 +++++++++++++++++++-------- README.ja.md | 27 +++++++++++++++++++-------- README.md | 27 +++++++++++++++++++-------- README.my.md | 27 +++++++++++++++++++-------- README.pt-br.md | 27 +++++++++++++++++++-------- README.vi.md | 27 +++++++++++++++++++-------- README.zh.md | 27 +++++++++++++++++++-------- assets/fui_log_page.jpg | Bin 0 -> 11049 bytes assets/fui_main_page.jpg | Bin 0 -> 34641 bytes assets/fui_setting_page.jpg | Bin 0 -> 46703 bytes assets/fui_web_page.jpg | Bin 0 -> 19639 bytes 13 files changed, 171 insertions(+), 72 deletions(-) create mode 100644 assets/fui_log_page.jpg create mode 100644 assets/fui_main_page.jpg create mode 100644 assets/fui_setting_page.jpg create mode 100644 assets/fui_web_page.jpg diff --git a/README.fr.md b/README.fr.md index a0cb84ce3..a26c89f14 100644 --- a/README.fr.md +++ b/README.fr.md @@ -306,7 +306,25 @@ Pour la documentation détaillée du TUI, voir [docs.picoclaw.io](https://docs.p Donnez une seconde vie à votre téléphone vieux de dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw. -**Option 1 : Termux (disponible maintenant)** +**Option 1 : Installation APK** + +Aperçu : + + + + + + + + +
+ +Téléchargez l'APK depuis [picoclaw.io](https://picoclaw.io/download/) et installez-le directement. Pas besoin de Termux ! + +**Option 2 : Termux** + +
+Terminal Launcher (pour les environnements à ressources limitées) 1. Installez [Termux](https://github.com/termux/termux-app) (téléchargez depuis [GitHub Releases](https://github.com/termux/termux-app/releases), ou cherchez dans F-Droid / Google Play) 2. Exécutez les commandes suivantes : @@ -323,13 +341,6 @@ Suivez ensuite la section Terminal Launcher ci-dessous pour terminer la configur PicoClaw on Termux -**Option 2 : Installation APK** - -Téléchargez l'APK depuis [picoclaw.io](https://picoclaw.io/download/) et installez-le directement. Pas besoin de Termux ! - -
-Terminal Launcher (pour les environnements à ressources limitées) - Pour les environnements minimaux où seul le binaire principal `picoclaw` est disponible (sans Launcher UI), vous pouvez tout configurer via la ligne de commande et un fichier de configuration JSON. **1. Initialiser** diff --git a/README.id.md b/README.id.md index bba010dec..d3c556dde 100644 --- a/README.id.md +++ b/README.id.md @@ -303,7 +303,25 @@ Untuk dokumentasi TUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw. -**Opsi 1: Termux (tersedia sekarang)** +**Opsi 1: Instal APK** + +Pratinjau: + + + + + + + + +
+ +Unduh APK dari [picoclaw.io](https://picoclaw.io/download/) dan instal langsung. Tanpa Termux! + +**Opsi 2: Termux** + +
+Terminal Launcher (untuk lingkungan dengan sumber daya terbatas) 1. Instal [Termux](https://github.com/termux/termux-app) (unduh dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play) 2. Jalankan perintah berikut: @@ -320,13 +338,6 @@ Kemudian ikuti bagian Terminal Launcher di bawah untuk menyelesaikan konfigurasi PicoClaw on Termux -**Opsi 2: Instal APK** - -Unduh APK dari [picoclaw.io](https://picoclaw.io/download/) dan instal langsung. Tanpa Termux! - -
-Terminal Launcher (untuk lingkungan dengan sumber daya terbatas) - Untuk lingkungan minimal di mana hanya binary inti `picoclaw` yang tersedia (tanpa Launcher UI), Anda dapat mengonfigurasi semuanya melalui command line dan file konfigurasi JSON. **1. Inisialisasi** diff --git a/README.it.md b/README.it.md index 50f08ad8b..6fe6c5e17 100644 --- a/README.it.md +++ b/README.it.md @@ -303,7 +303,25 @@ Per la documentazione dettagliata del TUI, vedi [docs.picoclaw.io](https://docs. Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw. -**Opzione 1: Termux (disponibile ora)** +**Opzione 1: Installazione APK** + +Anteprima: + + + + + + + + +
+ +Scarica l'APK da [picoclaw.io](https://picoclaw.io/download/) e installa direttamente. Senza Termux! + +**Opzione 2: Termux** + +
+Terminal Launcher (per ambienti con risorse limitate) 1. Installa [Termux](https://github.com/termux/termux-app) (scarica da [GitHub Releases](https://github.com/termux/termux-app/releases), o cerca su F-Droid / Google Play) 2. Esegui i seguenti comandi: @@ -320,13 +338,6 @@ Poi segui la sezione Terminal Launcher qui sotto per completare la configurazion PicoClaw on Termux -**Opzione 2: Installazione APK** - -Scarica l'APK da [picoclaw.io](https://picoclaw.io/download/) e installa direttamente. Senza Termux! - -
-Terminal Launcher (per ambienti con risorse limitate) - Per ambienti minimali dove è disponibile solo il binario core `picoclaw` (senza Launcher UI), puoi configurare tutto tramite riga di comando e un file di configurazione JSON. **1. Inizializza** diff --git a/README.ja.md b/README.ja.md index 7171a87b9..793c41fcb 100644 --- a/README.ja.md +++ b/README.ja.md @@ -303,7 +303,25 @@ TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.i 10 年前のスマホに第二の人生を!PicoClaw でスマート AI アシスタントに変身させましょう。 -**オプション 1: Termux(現在利用可能)** +**オプション 1: APK インストール** + +プレビュー: + + + + + + + + +
+ +[picoclaw.io](https://picoclaw.io/download/) から APK をダウンロードして直接インストール。Termux 不要! + +**オプション 2: Termux** + +
+Terminal Launcher(リソース制約環境向け) 1. [Termux](https://github.com/termux/termux-app) をインストール([GitHub Releases](https://github.com/termux/termux-app/releases) からダウンロード、または F-Droid / Google Play で検索) 2. 以下のコマンドを実行: @@ -320,13 +338,6 @@ termux-chroot ./picoclaw onboard # chroot で標準的な Linux ファイル PicoClaw on Termux -**オプション 2: APK インストール** - -[picoclaw.io](https://picoclaw.io/download/) から APK をダウンロードして直接インストール。Termux 不要! - -
-Terminal Launcher(リソース制約環境向け) - `picoclaw` コアバイナリのみが利用可能な最小環境(Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。 **1. 初期化** diff --git a/README.md b/README.md index db38e644f..d73348554 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,25 @@ For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io) Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. -**Option 1: Termux (available now)** +**Option 1: APK Install** + +Preview: + + + + + + + + +
+ +Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install directly. No Termux required! + +**Option 2: Termux** + +
+Terminal Launcher (for resource-constrained environments) 1. Install [Termux](https://github.com/termux/termux-app) (download from [GitHub Releases](https://github.com/termux/termux-app/releases), or search in F-Droid / Google Play) 2. Run the following commands: @@ -320,13 +338,6 @@ Then follow the Terminal Launcher section below to complete configuration. PicoClaw on Termux -**Option 2: APK Install** - -Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install directly. No Termux required! - -
-Terminal Launcher (for resource-constrained environments) - For minimal environments where only the `picoclaw` core binary is available (no Launcher UI), you can configure everything via the command line and a JSON config file. **1. Initialize** diff --git a/README.my.md b/README.my.md index 095d4b66a..f00fb438c 100644 --- a/README.my.md +++ b/README.my.md @@ -300,7 +300,25 @@ Untuk dokumentasi TUI terperinci, lihat [docs.picoclaw.io](https://docs.picoclaw Berikan telefon lama anda kehidupan baru! Jadikannya Pembantu AI pintar dengan PicoClaw. -**Pilihan 1: Termux (tersedia sekarang)** +**Pilihan 1: Pasang APK** + +Pratonton: + + + + + + + + +
+ +Muat turun APK dari [picoclaw.io](https://picoclaw.io/download/) dan pasang secara langsung. Tiada Termux diperlukan! + +**Pilihan 2: Termux** + +
+Pelancar Terminal (untuk persekitaran terhad sumber) 1. Pasang [Termux](https://github.com/termux/termux-app) (muat turun dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play) 2. Jalankan arahan berikut: @@ -317,13 +335,6 @@ Kemudian ikuti bahagian Pelancar Terminal di bawah untuk melengkapkan konfiguras PicoClaw pada Termux -**Pilihan 2: Pasang APK** - -Muat turun APK dari [picoclaw.io](https://picoclaw.io/download/) dan pasang secara langsung. Tiada Termux diperlukan! - -
-Pelancar Terminal (untuk persekitaran terhad sumber) - Untuk persekitaran minimal di mana hanya binari teras `picoclaw` tersedia (tiada UI Pelancar), anda boleh mengkonfigurasi semua melalui baris arahan dan fail konfigurasi JSON. **1. Mulakan** diff --git a/README.pt-br.md b/README.pt-br.md index bbc5b4957..db11d4d82 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -303,7 +303,25 @@ Para documentação detalhada do TUI, veja [docs.picoclaw.io](https://docs.picoc Dê uma segunda vida ao seu celular de uma década! Transforme-o em um Assistente de IA inteligente com o PicoClaw. -**Opção 1: Termux (disponível agora)** +**Opção 1: Instalação via APK** + +Pré-visualização: + + + + + + + + +
+ +Baixe o APK de [picoclaw.io](https://picoclaw.io/download/) e instale diretamente. Sem necessidade de Termux! + +**Opção 2: Termux** + +
+Terminal Launcher (para ambientes com recursos limitados) 1. Instale o [Termux](https://github.com/termux/termux-app) (baixe nas [GitHub Releases](https://github.com/termux/termux-app/releases), ou pesquise no F-Droid / Google Play) 2. Execute os seguintes comandos: @@ -320,13 +338,6 @@ Em seguida, siga a seção Terminal Launcher abaixo para concluir a configuraç PicoClaw on Termux -**Opção 2: Instalação via APK** - -Baixe o APK de [picoclaw.io](https://picoclaw.io/download/) e instale diretamente. Sem necessidade de Termux! - -
-Terminal Launcher (para ambientes com recursos limitados) - Para ambientes mínimos onde apenas o binário principal `picoclaw` está disponível (sem Launcher UI), você pode configurar tudo via linha de comando e um arquivo de configuração JSON. **1. Inicializar** diff --git a/README.vi.md b/README.vi.md index 7ae414723..78b8a9a59 100644 --- a/README.vi.md +++ b/README.vi.md @@ -303,7 +303,25 @@ Sử dụng menu TUI để: **1)** Cấu hình Provider -> **2)** Cấu hình Ch Hãy cho chiếc điện thoại cũ của bạn một cuộc sống mới! Biến nó thành Trợ lý AI thông minh với PicoClaw. -**Tùy chọn 1: Termux (có sẵn ngay)** +**Tùy chọn 1: Cài đặt APK** + +Xem trước: + + + + + + + + +
+ +Tải APK từ [picoclaw.io](https://picoclaw.io/download/) và cài đặt trực tiếp. Không cần Termux! + +**Tùy chọn 2: Termux** + +
+Terminal Launcher (cho môi trường hạn chế tài nguyên) 1. Cài đặt [Termux](https://github.com/termux/termux-app) (tải từ [GitHub Releases](https://github.com/termux/termux-app/releases), hoặc tìm kiếm trong F-Droid / Google Play) 2. Chạy các lệnh sau: @@ -320,13 +338,6 @@ Sau đó làm theo phần Terminal Launcher bên dưới để hoàn tất cấu PicoClaw on Termux -**Tùy chọn 2: Cài đặt APK** - -Tải APK từ [picoclaw.io](https://picoclaw.io/download/) và cài đặt trực tiếp. Không cần Termux! - -
-Terminal Launcher (cho môi trường hạn chế tài nguyên) - Đối với các môi trường tối giản chỉ có binary lõi `picoclaw` (không có Launcher UI), bạn có thể cấu hình mọi thứ qua dòng lệnh và tệp cấu hình JSON. **1. Khởi tạo** diff --git a/README.zh.md b/README.zh.md index 569ca1656..16d01b59b 100644 --- a/README.zh.md +++ b/README.zh.md @@ -303,7 +303,25 @@ picoclaw-launcher-tui 让你十年前的旧手机焕发新生!将它变成你的 AI 助手。 -**方式一:Termux(现已可用)** +**方式一:APK 安装** + +预览: + + + + + + + + +
+ +从 [picoclaw.io](https://picoclaw.io/download/) 下载 APK 并直接安装,无需 Termux! + +**方式二:Termux** + +
+Terminal Launcher(适用于资源受限环境) 1. 安装 [Termux](https://github.com/termux/termux-app)(可从 [GitHub Releases](https://github.com/termux/termux-app/releases) 下载,或在 F-Droid / Google Play 中搜索) 2. 执行以下命令: @@ -320,13 +338,6 @@ termux-chroot ./picoclaw onboard # chroot 提供标准 Linux 文件系统布 PicoClaw on Termux -**方式二:APK 安装** - -从 [picoclaw.io](https://picoclaw.io/download/) 下载 APK 并直接安装,无需 Termux! - -
-Terminal Launcher(适用于资源受限环境) - 对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI),可通过命令行和 JSON 配置文件完成所有配置。 **1. 初始化** diff --git a/assets/fui_log_page.jpg b/assets/fui_log_page.jpg new file mode 100644 index 0000000000000000000000000000000000000000..188c4698278599adf53412d2e0a0da054c4fe7b7 GIT binary patch literal 11049 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 literal 0 HcmV?d00001 diff --git a/assets/fui_main_page.jpg b/assets/fui_main_page.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f9c5b5c348ef7367ab30fbfb6def8a4b72327ad4 GIT binary patch literal 34641 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 OdEbFYeEVd!7>(^ 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

#EAkwEBzd`c z)?e~CUA0B-8?ZG-LlHx!v!7!XCSnI|^KYJ3l2=azcEVMp)$^Or?78uo2xs+^p+(t! z<$+dHrsYvR*uxi4P02gz57wZY-wE!kSXGVqym)5-%FistWmP6-IxNJA4_IhbV|8sG z+{?6v&pP=aXBePK(DSS8uvkW(Jo0(c`z4zH=wx?~EV8i3Nj=ehGtLq_o;$p8@iJ99 zVYXFjnG$kp+Yd9oL^J$sIIn7JeKoBt!K0-I0rhL_uUft)tV)Z*_hiST2$Q2yD<#-N}dNCE@6heeII zPv-Ff1Va&Qumt$<+tr68KCqAzwXuZ)T}GZsggKm6#e9=Sw#*s{D8XRsijeiOPq$mm zl2qVjUN&?gH`uSlWjUe?*3Gx*mcqllGQZkZt;@0uX$jHuJW&h=+cOul!zNkhq3@zh zR?L--U^VVe%@ED36O|^mY=mOqiJ+b?R$FGNe{SE8i4r2Dy$-UIc&u%zkkl!kTe~i%v0M3e? z1ptn;xxRk#E1$D(3w-sr70UrQ3{4(JRV0<8t*Uxo-uV8utYzhkbzO>#MmS_WqB$nu zVcI3>+gS|8HF1 z89|6BQq*Qs6UFW3es?3vFGYkxv`UzPe>^E>OPy36;HXGV{PIEem!vR2cbA{l)G%d= zLN`h`EbBsk{g#-j^4XFow#bMT$b)=T{Cnr`pL^U*CMSG#bN!LCV|Ht(P8~XZ4RZY+ zwMvkuY~u|Ktlsnv2xYrFDmFAQGal#o!@8{mf|zrsUlo+4r*Dj-oEMSkTR2fry7U@& zdtogp&B6r*Ud-Mc`=iMH|1Gi|t2UvJG{Dl6ad?w+0)DJ#L1 zKaevlrmjcwMUke6>d*+4#b^6e!9f8InX_ACa~7VT6MnY-_7~k}fA6aPp{)0q)}_`5 fx39k%f3eRZs}vCNTjt(-@-f}sWB-x4-SPhcWY>n- literal 0 HcmV?d00001 From bae4342af1d0a27aa37c52c6e1689d340aaa7048 Mon Sep 17 00:00:00 2001 From: Mauro Date: Thu, 2 Apr 2026 12:49:08 +0200 Subject: [PATCH 5/5] Feat/tool read_file by lines (#1981) * feat(tool): read_file tool by lines * fix test * restore old bytes read_file tool * unified read_file tool * revert * fix doc * fix test * fix doc * fix offset * fix default start_line * fix line format * fix bug * removed legacy test * enhanced infos * improvements * feat(tool): read_file tool by lines --- config/config.example.json | 3 +- docs/configuration.md | 60 +++++ docs/it/configuration.md | 219 ----------------- pkg/agent/instance.go | 7 +- pkg/agent/instance_test.go | 41 ++++ pkg/config/config.go | 21 +- pkg/config/config_test.go | 7 + pkg/config/defaults.go | 1 + pkg/tools/filesystem.go | 382 ++++++++++++++++++++++++++++- pkg/tools/filesystem_test.go | 449 ++++++++++++++++++++++++++++++++--- 10 files changed, 936 insertions(+), 254 deletions(-) delete mode 100644 docs/it/configuration.md diff --git a/config/config.example.json b/config/config.example.json index bedd543d7..f0cce6d72 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -421,7 +421,8 @@ "enabled": true }, "read_file": { - "enabled": true + "enabled": true, + "mode": "bytes" }, "send_tts": { "enabled": false diff --git a/docs/configuration.md b/docs/configuration.md index 58930cbfa..7a5902f58 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -301,6 +301,66 @@ Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous | `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace | | `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace | +### Read File Mode + +`read_file` has two mutually exclusive implementations selected by config. PicoClaw registers exactly one of them at startup: + +| Config Key | Type | Default | Description | +|------------|------|---------|-------------| +| `tools.read_file.enabled` | bool | `true` | Enables the `read_file` tool | +| `tools.read_file.mode` | string | `bytes` | Selects the `read_file` implementation: `bytes` or `lines` | +| `tools.read_file.max_read_file_size` | int | `65536` | Maximum bytes returned by `read_file` | + +#### Mode: `bytes` + +Optimized for arbitrary files and binary-safe pagination. + +Parameters: + +* `path` (required): File path +* `offset` (optional): Starting byte offset, default `0` +* `length` (optional): Maximum number of bytes to read, default `max_read_file_size` + +Use `bytes` when: + +* You may read binary files +* You want deterministic byte-range pagination + +#### Mode: `lines` + +Text-oriented behavior, optimized for source files, markdown, logs, and configs. The tool reads sequentially by line and stops when the configured byte budget is reached. + +Parameters: + +* `path` (required): File path +* `start_line` (optional): Starting line number, 1-indexed and inclusive, default `1` +* `max_lines` (optional): Maximum number of lines to read, default = all remaining lines until EOF or byte budget + +Behavior notes: + +* Binary-looking files are rejected with guidance to switch `read_file` to `mode = bytes` +* Extremely long single lines are truncated rather than skipped + +Use `mode = lines` when: + +* The agent mostly reads text files +* You want line-based pagination in prompts and tool calls +* You want cleaner chunks for code review, logs, and documentation + +#### Example + +```json +{ + "tools": { + "read_file": { + "enabled": true, + "mode": "lines", + "max_read_file_size": 65536 + } + } +} +``` + ### Exec Security | Config Key | Type | Default | Description | diff --git a/docs/it/configuration.md b/docs/it/configuration.md deleted file mode 100644 index 6a79a9543..000000000 --- a/docs/it/configuration.md +++ /dev/null @@ -1,219 +0,0 @@ -# ⚙️ Guida alla Configurazione - -> Torna al [README](../../README.md) - -## ⚙️ Configurazione - -File di configurazione: `~/.picoclaw/config.json` - -### Variabili d'Ambiente - -Puoi sovrascrivere i percorsi predefiniti usando variabili d'ambiente. Questo è utile per installazioni portatili, distribuzioni containerizzate, o per eseguire picoclaw come servizio di sistema. Queste variabili sono indipendenti e controllano percorsi diversi. - -| Variabile | Descrizione | Percorso Predefinito | -|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| -| `PICOCLAW_CONFIG` | Sovrascrive il percorso al file di configurazione. Indica direttamente a picoclaw quale `config.json` caricare, ignorando tutte le altre posizioni. | `~/.picoclaw/config.json` | -| `PICOCLAW_HOME` | Sovrascrive la directory radice per i dati di picoclaw. Modifica la posizione predefinita del `workspace` e delle altre directory dati. | `~/.picoclaw` | - -**Esempi:** - -```bash -# Esegui picoclaw usando un file di configurazione specifico -# Il percorso del workspace verrà letto da quel file di configurazione -PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway - -# Esegui picoclaw con tutti i dati salvati in /opt/picoclaw -# La configurazione verrà caricata dal percorso predefinito ~/.picoclaw/config.json -# Il workspace verrà creato in /opt/picoclaw/workspace -PICOCLAW_HOME=/opt/picoclaw picoclaw agent - -# Usa entrambi per un setup completamente personalizzato -PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway -``` - -### Struttura del Workspace - -PicoClaw salva i dati nel workspace configurato (predefinito: `~/.picoclaw/workspace`): - -``` -~/.picoclaw/workspace/ -├── sessions/ # Sessioni di conversazione e cronologia -├── memory/ # Memoria a lungo termine (MEMORY.md) -├── state/ # Stato persistente (ultimo canale, ecc.) -├── cron/ # Database dei job pianificati -├── skills/ # Skill personalizzate -├── AGENTS.md # Guida al comportamento dell'agent -├── HEARTBEAT.md # Prompt per task periodici (controllato ogni 30 min) -├── IDENTITY.md # Identità dell'agent -├── SOUL.md # Anima dell'agent -└── USER.md # Preferenze dell'utente -``` - -> **Nota:** Le modifiche a `AGENTS.md`, `SOUL.md`, `USER.md`, `IDENTITY.md` e `memory/MEMORY.md` vengono rilevate automaticamente a runtime tramite il tracciamento della data di modifica (mtime). **Non è necessario riavviare il gateway** dopo aver modificato questi file — l'agent caricherà il nuovo contenuto alla prossima richiesta. - -### Sorgenti delle Skill - -Per impostazione predefinita, le skill vengono caricate da: - -1. `~/.picoclaw/workspace/skills` (workspace) -2. `~/.picoclaw/skills` (globale) -3. `/skills` (builtin) - -Per configurazioni avanzate/di test, puoi sovrascrivere la directory radice delle skill builtin con: - -```bash -export PICOCLAW_BUILTIN_SKILLS=/path/to/skills -``` - -### Politica Unificata di Esecuzione dei Comandi - -- I comandi slash generici vengono eseguiti tramite un unico percorso in `pkg/agent/loop.go` via `commands.Executor`. -- Gli adattatori dei canali non consumano più localmente i comandi generici; inoltrano il testo in entrata al percorso bus/agent. Telegram registra ancora automaticamente i comandi supportati all'avvio. -- Un comando slash sconosciuto (ad esempio `/foo`) viene passato all'elaborazione LLM come se fosse un messaggio dell'utente. -- Un comando registrato ma non supportato sul canale corrente (ad esempio `/show` su WhatsApp) restituisce un errore esplicito all'utente e interrompe l'elaborazione. - -### 🔒 Sandbox di Sicurezza - -PicoClaw esegue in un ambiente sandboxed per impostazione predefinita. L'agent può accedere solo ai file ed eseguire comandi all'interno del workspace configurato. - -#### Configurazione Predefinita - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "restrict_to_workspace": true - } - } -} -``` - -| Opzione | Predefinito | Descrizione | -| ----------------------- | ----------------------- | ---------------------------------------------------- | -| `workspace` | `~/.picoclaw/workspace` | Directory di lavoro dell'agent | -| `restrict_to_workspace` | `true` | Limita l'accesso a file/comandi al workspace | - -#### Strumenti Protetti - -Quando `restrict_to_workspace: true`, i seguenti strumenti sono in sandbox: - -| Strumento | Funzione | Restrizione | -| ------------- | ------------------------- | ---------------------------------------------------- | -| `read_file` | Legge file | Solo file all'interno del workspace | -| `write_file` | Scrive file | Solo file all'interno del workspace | -| `list_dir` | Elenca directory | Solo directory all'interno del workspace | -| `edit_file` | Modifica file | Solo file all'interno del workspace | -| `append_file` | Aggiunge ai file | Solo file all'interno del workspace | -| `exec` | Esegue comandi | I percorsi dei comandi devono essere nel workspace | - -#### Protezione Exec Aggiuntiva - -Anche con `restrict_to_workspace: false`, lo strumento `exec` blocca questi comandi pericolosi: - -* `rm -rf`, `del /f`, `rmdir /s` — Cancellazione di massa -* `format`, `mkfs`, `diskpart` — Formattazione del disco -* `dd if=` — Imaging del disco -* Scrittura su `/dev/sd[a-z]` — Scritture dirette su disco -* `shutdown`, `reboot`, `poweroff` — Spegnimento del sistema -* Fork bomb `:(){ :|:& };:` - -### Controllo Accesso ai File - -| Chiave di configurazione | Tipo | Predefinito | Descrizione | -|--------------------------|------|-------------|-------------| -| `tools.allow_read_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la lettura al di fuori del workspace | -| `tools.allow_write_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la scrittura al di fuori del workspace | - -### Sicurezza Exec - -| Chiave di configurazione | Tipo | Predefinito | Descrizione | -|--------------------------|------|-------------|-------------| -| `tools.exec.allow_remote` | bool | `false` | Consente lo strumento exec da canali remoti (Telegram/Discord ecc.) | -| `tools.exec.enable_deny_patterns` | bool | `true` | Abilita l'intercettazione dei comandi pericolosi | -| `tools.exec.custom_deny_patterns` | string[] | `[]` | Pattern regex personalizzati da bloccare | -| `tools.exec.custom_allow_patterns` | string[] | `[]` | Pattern regex personalizzati da consentire | - -> **Nota di sicurezza:** La protezione dei symlink è abilitata per impostazione predefinita — tutti i percorsi file vengono risolti tramite `filepath.EvalSymlinks` prima del confronto con la whitelist, prevenendo attacchi di escape tramite symlink. - -#### Limitazione Nota: Processi Figlio degli Strumenti di Build - -Il controllo di sicurezza exec ispeziona solo la riga di comando avviata direttamente da PicoClaw. Non ispeziona ricorsivamente i processi figlio generati da strumenti di sviluppo consentiti come `make`, `go run`, `cargo`, `npm run` o script di build personalizzati. - -Ciò significa che un comando di primo livello può comunque compilare o avviare altri binari dopo aver superato il controllo iniziale. In pratica, tratta gli script di build, i Makefile, gli script di pacchetti e i binari generati come codice eseguibile che richiede lo stesso livello di revisione di un comando shell diretto. - -Per ambienti ad alto rischio: - -* Esamina gli script di build prima dell'esecuzione. -* Preferisci l'approvazione/revisione manuale per i workflow di compilazione ed esecuzione. -* Esegui PicoClaw in un container o VM se hai bisogno di un isolamento più forte di quello fornito dal controllo integrato. - -#### Esempi di Errore - -``` -[ERROR] tool: Tool execution failed -{tool=exec, error=Command blocked by safety guard (path outside working dir)} -``` - -``` -[ERROR] tool: Tool execution failed -{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} -``` - -#### Disabilitare le Restrizioni (Rischio di Sicurezza) - -Se hai bisogno che l'agent acceda a percorsi al di fuori del workspace: - -**Metodo 1: File di configurazione** - -```json -{ - "agents": { - "defaults": { - "restrict_to_workspace": false - } - } -} -``` - -**Metodo 2: Variabile d'ambiente** - -```bash -export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false -``` - -> ⚠️ **Attenzione**: Disabilitare questa restrizione consente all'agent di accedere a qualsiasi percorso sul tuo sistema. Usare con cautela solo in ambienti controllati. - -#### Coerenza dei Confini di Sicurezza - -L'impostazione `restrict_to_workspace` si applica in modo coerente a tutti i percorsi di esecuzione: - -| Percorso di esecuzione | Confine di sicurezza | -| ---------------------- | --------------------------------- | -| Main Agent | `restrict_to_workspace` ✅ | -| Subagent / Spawn | Eredita la stessa restrizione ✅ | -| Heartbeat tasks | Eredita la stessa restrizione ✅ | - -Tutti i percorsi condividono la stessa restrizione del workspace — non è possibile aggirare il confine di sicurezza tramite subagent o task pianificati. - -### Heartbeat (Task Periodici) - -PicoClaw può eseguire task periodici automaticamente. Crea un file `HEARTBEAT.md` nel tuo workspace: - -```markdown -# Periodic Tasks - -- Check my email for important messages -- Review my calendar for upcoming events -- Check the weather forecast -``` - -L'agent leggerà questo file ogni 30 minuti (configurabile) ed eseguirà tutti i task usando gli strumenti disponibili. - -#### Task Asincroni con Spawn - -Per task di lunga durata (ricerca web, chiamate API), usa lo strumento `spawn` per creare un **subagent**: - -```markdown -# Periodic Tasks -``` diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 880725660..bacfa49c5 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -77,7 +77,12 @@ func NewAgentInstance( if cfg.Tools.IsToolEnabled("read_file") { maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize - toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths)) + switch cfg.Tools.ReadFile.EffectiveMode() { + case config.ReadFileModeLines: + toolsRegistry.Register(tools.NewReadFileLinesTool(workspace, readRestrict, maxReadFileSize, allowReadPaths)) + default: + toolsRegistry.Register(tools.NewReadFileBytesTool(workspace, readRestrict, maxReadFileSize, allowReadPaths)) + } } if cfg.Tools.IsToolEnabled("write_file") { toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths)) diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index e296a18cb..7c043d88f 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -248,6 +248,47 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) { } } +func TestNewAgentInstance_ReadFileModeSelectsSchema(t *testing.T) { + workspace := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "test-model", + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{ + Enabled: true, + Mode: config.ReadFileModeLines, + MaxReadFileSize: 4096, + }, + }, + } + + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{}) + readTool, ok := agent.Tools.Get("read_file") + if !ok { + t.Fatal("read_file tool not registered") + } + + params := readTool.Parameters() + props, _ := params["properties"].(map[string]any) + if _, ok := props["start_line"]; !ok { + t.Fatalf("expected line-mode schema to expose start_line, got %#v", props) + } + if _, ok := props["max_lines"]; !ok { + t.Fatalf("expected line-mode schema to expose max_lines, got %#v", props) + } + if _, ok := props["offset"]; ok { + t.Fatalf("did not expect line-mode schema to expose offset, got %#v", props) + } + if _, ok := props["length"]; ok { + t.Fatalf("did not expect line-mode schema to expose length, got %#v", props) + } +} + func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) { workspace := t.TempDir() diff --git a/pkg/config/config.go b/pkg/config/config.go index fcedf45b9..30e5e1dd9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -803,8 +803,25 @@ type MediaCleanupConfig struct { } type ReadFileToolConfig struct { - Enabled bool `json:"enabled"` - MaxReadFileSize int `json:"max_read_file_size"` + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + MaxReadFileSize int `json:"max_read_file_size"` +} + +const ( + ReadFileModeBytes = "bytes" + ReadFileModeLines = "lines" +) + +func (c ReadFileToolConfig) EffectiveMode() string { + switch strings.ToLower(strings.TrimSpace(c.Mode)) { + case ReadFileModeLines: + return ReadFileModeLines + case "", ReadFileModeBytes: + return ReadFileModeBytes + default: + return ReadFileModeBytes + } } type ToolsConfig struct { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 278dfa43a..a1410f940 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -317,6 +317,13 @@ func TestDefaultConfig_WebTools(t *testing.T) { } } +func TestDefaultConfig_ReadFileMode(t *testing.T) { + cfg := DefaultConfig() + if cfg.Tools.ReadFile.EffectiveMode() != ReadFileModeBytes { + t.Fatalf("expected default read_file mode %q, got %q", ReadFileModeBytes, cfg.Tools.ReadFile.EffectiveMode()) + } +} + func TestSaveConfig_FilePermissions(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("file permission bits are not enforced on Windows") diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index a9a107975..39cdb89e6 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -487,6 +487,7 @@ func DefaultConfig() *Config { }, ReadFile: ReadFileToolConfig{ Enabled: true, + Mode: ReadFileModeBytes, MaxReadFileSize: 64 * 1024, // 64KB }, Spawn: ToolConfig{ diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 39d45013d..0b9a16950 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -1,18 +1,22 @@ package tools import ( + "bufio" + "bytes" "context" "errors" "fmt" "io" "io/fs" "math" + "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "time" + "unicode/utf8" "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/logger" @@ -20,7 +24,11 @@ import ( const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow -func validatePathWithAllowPaths(path, workspace string, restrict bool, patterns []*regexp.Regexp) (string, error) { +func validatePathWithAllowPaths( + path, workspace string, + restrict bool, + patterns []*regexp.Regexp, +) (string, error) { if workspace == "" { return path, fmt.Errorf("workspace is not defined") } @@ -253,6 +261,11 @@ type ReadFileTool struct { maxSize int64 } +type ReadFileLinesTool struct { + fs fileSystem + maxSize int64 +} + func NewReadFileTool( workspace string, restrict bool, @@ -275,14 +288,53 @@ func NewReadFileTool( } } +func NewReadFileBytesTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileTool { + return NewReadFileTool(workspace, restrict, maxReadFileSize, allowPaths...) +} + +func NewReadFileLinesTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileLinesTool { + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] + } + + maxSize := int64(maxReadFileSize) + if maxSize <= 0 { + maxSize = MaxReadFileSize + } + + return &ReadFileLinesTool{ + fs: buildFs(workspace, restrict, patterns), + maxSize: maxSize, + } +} + func (t *ReadFileTool) Name() string { return "read_file" } +func (t *ReadFileLinesTool) Name() string { + return "read_file" +} + func (t *ReadFileTool) Description() string { return "Read the contents of a file. Supports pagination via `offset` and `length`." } +func (t *ReadFileLinesTool) Description() string { + return "Read a UTF-8 text file from the filesystem. Output always includes line numbers in the format `LINE_NUMBER|LINE_CONTENT` (1-indexed). Supports partial reads via `start_line` and `max_lines` for large text files." +} + func (t *ReadFileTool) Parameters() map[string]any { return map[string]any{ "type": "object", @@ -306,6 +358,28 @@ func (t *ReadFileTool) Parameters() map[string]any { } } +func (t *ReadFileLinesTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Path to the file to read.", + }, + "start_line": map[string]any{ + "type": "integer", + "description": "Line number to start reading from (1-indexed, inclusive).", + "default": 1, + }, + "max_lines": map[string]any{ + "type": "integer", + "description": "Maximum number of lines to read.", + }, + }, + "required": []string{"path"}, + } +} + func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) if !ok { @@ -447,6 +521,302 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe return NewToolResult(header + "\n\n" + string(data)) } +func (t *ReadFileLinesTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + path, ok := args["path"].(string) + if !ok { + return ErrorResult("path is required") + } + + startLine, err := getInt64Arg(args, "start_line", 1) + if err != nil { + return ErrorResult(err.Error()) + } + if startLine < 1 { + return ErrorResult("start_line must be >= 1") + } + if _, exists := args["offset"]; exists { + return ErrorResult("offset is not supported in line mode; use start_line") + } + if _, exists := args["length"]; exists { + return ErrorResult("length is not supported in line mode; use max_lines") + } + if _, exists := args["limit"]; exists { + return ErrorResult("limit is not supported in line mode; use max_lines") + } + + limit := int64(-1) + if raw, exists := args["max_lines"]; exists && raw != nil { + limit, err = getInt64Arg(args, "max_lines", -1) + if err != nil { + return ErrorResult(err.Error()) + } + if limit <= 0 { + return ErrorResult("max_lines, if provided, must be > 0") + } + } + + file, err := t.fs.Open(path) + if err != nil { + return ErrorResult(err.Error()) + } + defer file.Close() + + if info, statErr := file.Stat(); statErr == nil && info.IsDir() { + return ErrorResult(fmt.Sprintf("failed to open file: path is a directory: %s", path)) + } + + sample := make([]byte, 512) + sampleN, readErr := file.Read(sample) + if readErr != nil && readErr != io.EOF { + return ErrorResult(fmt.Sprintf("failed to read file: %v", readErr)) + } + sample = sample[:sampleN] + if isBinaryReadFileData(sample) { + return ErrorResult("file appears to be binary; switch read_file mode to 'bytes' for byte-based inspection") + } + + reader := bufio.NewReaderSize(io.MultiReader(bytes.NewReader(sample), file), 32*1024) + + var content strings.Builder + lineIndex := int64(1) + var linesRead int64 + var fileBytesRead int64 + var outputBytesRead int64 + var reachedEOF bool + var byteBudgetTruncated bool + var lineTruncated bool + + for lineIndex < startLine { + hasLine, consumeErr := consumeNextLine(reader) + if consumeErr != nil { + return ErrorResult(fmt.Sprintf("failed to read file content: %v", consumeErr)) + } + if !hasLine { + reachedEOF = true + break + } + lineIndex++ + } + + for !reachedEOF && (limit < 0 || linesRead < limit) { + prefix := formatReadFileLinePrefix(lineIndex) + remaining := t.maxSize - outputBytesRead - int64(len(prefix)) + if remaining <= 0 { + byteBudgetTruncated = true + break + } + + line, complete, hasLine, readLineErr := readNextLinePrefix(reader, remaining) + if readLineErr != nil { + return ErrorResult(fmt.Sprintf("failed to read file content: %v", readLineErr)) + } + if !hasLine { + reachedEOF = true + break + } + + content.WriteString(prefix) + content.Write(line) + fileBytesRead += int64(len(line)) + outputBytesRead += int64(len(prefix) + len(line)) + linesRead++ + lineIndex++ + + if !complete { + byteBudgetTruncated = true + lineTruncated = true + break + } + } + + if !reachedEOF && !lineTruncated { + hasMoreContent, peekErr := readerHasMoreContent(reader) + if peekErr != nil { + return ErrorResult(fmt.Sprintf("failed to inspect remaining file content: %v", peekErr)) + } + if !hasMoreContent { + reachedEOF = true + byteBudgetTruncated = false + } + } + + if linesRead == 0 && content.Len() == 0 { + return NewToolResult(fmt.Sprintf("[END OF FILE - no content at or after start_line=%d]", startLine)) + } + + start := startLine + endLine := startLine + linesRead - 1 + displayPath := filepath.Base(path) + header := fmt.Sprintf( + "[file: %s | read: lines %d-%d (1-indexed) | file_bytes: %d | output_bytes: %d]", + displayPath, start, endLine, fileBytesRead, outputBytesRead, + ) + + switch { + case lineTruncated: + header += fmt.Sprintf( + "\n[TRUNCATED - line %d exceeded the %d byte read budget and was cut mid-line.]", + endLine, + t.maxSize, + ) + case byteBudgetTruncated: + if limit > 0 { + header += fmt.Sprintf( + "\n[TRUNCATED - byte budget reached. Call read_file again with start_line=%d and max_lines=%d to continue at the next line.]", + startLine+linesRead, + limit, + ) + } else { + header += fmt.Sprintf( + "\n[TRUNCATED - byte budget reached. Call read_file again with start_line=%d to continue at the next line.]", + startLine+linesRead, + ) + } + case !reachedEOF && limit > 0 && linesRead >= limit: + header += fmt.Sprintf( + "\n[PARTIAL - more content remains. Call read_file again with start_line=%d and max_lines=%d to continue.]", + startLine+linesRead, + limit, + ) + default: + header += "\n[END OF FILE - no further content.]" + } + + logger.DebugCF("tool", "ReadFileTool execution completed successfully", + map[string]any{ + "path": path, + "lines_read": linesRead, + "file_bytes_read": fileBytesRead, + "output_bytes_read": outputBytesRead, + "truncated": byteBudgetTruncated, + "tool": t.Name(), + }) + + return NewToolResult(header + "\n\n" + content.String()) +} + +func formatReadFileLinePrefix(lineNumber int64) string { + return strconv.FormatInt(lineNumber, 10) + "|" +} + +func isBinaryReadFileData(data []byte) bool { + if len(data) == 0 { + return false + } + + sample := data + if len(sample) > 512 { + sample = sample[:512] + } + + if bytes.IndexByte(sample, 0) >= 0 { + return true + } + + contentType := http.DetectContentType(sample) + if strings.HasPrefix(contentType, "text/") { + return false + } + if strings.HasSuffix(contentType, "/json") || + strings.HasSuffix(contentType, "+json") || + strings.HasSuffix(contentType, "/xml") || + strings.HasSuffix(contentType, "+xml") || + strings.Contains(contentType, "javascript") { + return false + } + + if !utf8.Valid(sample) { + return true + } + + controlChars := 0 + for _, b := range sample { + if b < 0x20 && b != '\n' && b != '\r' && b != '\t' && b != '\f' && b != '\b' { + controlChars++ + } + } + + return float64(controlChars)/float64(len(sample)) > 0.1 +} + +func consumeNextLine(reader *bufio.Reader) (bool, error) { + sawData := false + + for { + fragment, err := reader.ReadSlice('\n') + if len(fragment) > 0 { + sawData = true + } + + switch { + case err == nil: + return true, nil + case errors.Is(err, bufio.ErrBufferFull): + continue + case errors.Is(err, io.EOF): + return sawData, nil + default: + return false, err + } + } +} + +func readNextLinePrefix(reader *bufio.Reader, maxBytes int64) ([]byte, bool, bool, error) { + if maxBytes <= 0 { + return nil, false, false, nil + } + + var out bytes.Buffer + sawData := false + complete := true + + for { + fragment, err := reader.ReadSlice('\n') + if len(fragment) > 0 { + sawData = true + if remaining := maxBytes - int64(out.Len()); remaining > 0 { + take := len(fragment) + if int64(take) > remaining { + take = int(remaining) + complete = false + } + out.Write(fragment[:take]) + } else { + complete = false + } + } + + switch { + case err == nil: + return out.Bytes(), complete, sawData, nil + case errors.Is(err, bufio.ErrBufferFull): + if !complete { + return out.Bytes(), false, true, nil + } + continue + case errors.Is(err, io.EOF): + if !sawData { + return nil, true, false, nil + } + return out.Bytes(), complete, true, nil + default: + return nil, false, false, err + } + } +} + +func readerHasMoreContent(reader *bufio.Reader) (bool, error) { + _, err := reader.Peek(1) + switch { + case err == nil: + return true, nil + case errors.Is(err, io.EOF): + return false, nil + default: + return false, err + } +} + // getInt64Arg extracts an integer argument from the args map, returning the // provided default if the key is absent. func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) { @@ -483,7 +853,11 @@ type WriteFileTool struct { fs fileSystem } -func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool { +func NewWriteFileTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *WriteFileTool { var patterns []*regexp.Regexp if len(allowPaths) > 0 { patterns = allowPaths[0] @@ -536,7 +910,9 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR if !overwrite { if _, err := t.fs.Open(path); err == nil { - return ErrorResult(fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path)) + return ErrorResult( + fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path), + ) } } diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go index 0b4dd310b..bfbc1f46e 100644 --- a/pkg/tools/filesystem_test.go +++ b/pkg/tools/filesystem_test.go @@ -18,7 +18,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) { testFile := filepath.Join(tmpDir, "test.txt") os.WriteFile(testFile, []byte("test content"), 0o644) - tool := NewReadFileTool("", false, MaxReadFileSize) + tool := NewReadFileBytesTool("", false, MaxReadFileSize) ctx := context.Background() args := map[string]any{ "path": testFile, @@ -45,7 +45,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) { // TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { - tool := NewReadFileTool("", false, MaxReadFileSize) + tool := NewReadFileBytesTool("", false, MaxReadFileSize) ctx := context.Background() args := map[string]any{ "path": "/nonexistent_file_12345.txt", @@ -59,8 +59,13 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { } // Should contain error message - if !strings.Contains(result.ForLLM, "failed to open file") && !strings.Contains(result.ForUser, "failed to read") { - t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) + if !strings.Contains(result.ForLLM, "failed to open file") && + !strings.Contains(result.ForUser, "failed to open") { + t.Errorf( + "Expected error message, got ForLLM: %s, ForUser: %s", + result.ForLLM, + result.ForUser, + ) } } @@ -78,7 +83,8 @@ func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) { } // Should mention required parameter - if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") { + if !strings.Contains(result.ForLLM, "path is required") && + !strings.Contains(result.ForUser, "path is required") { t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM) } } @@ -297,7 +303,12 @@ func TestFilesystemTool_WriteFile_OverwriteSandboxed(t *testing.T) { "content": "replaced in sandbox", "overwrite": true, }) - assert.False(t, result.IsError, "expected success in sandbox mode with overwrite=true, got: %s", result.ForLLM) + assert.False( + t, + result.IsError, + "expected success in sandbox mode with overwrite=true, got: %s", + result.ForLLM, + ) data, err := os.ReadFile(filepath.Join(workspace, testFile)) assert.NoError(t, err) @@ -325,7 +336,8 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) { } // Should list files and directories - if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") { + if !strings.Contains(result.ForLLM, "file1.txt") || + !strings.Contains(result.ForLLM, "file2.txt") { t.Errorf("Expected files in listing, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "subdir") { @@ -349,8 +361,13 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) { } // Should contain error message - if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") { - t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) + if !strings.Contains(result.ForLLM, "failed to read") && + !strings.Contains(result.ForUser, "failed to read") { + t.Errorf( + "Expected error message, got ForLLM: %s, ForUser: %s", + result.ForLLM, + result.ForUser, + ) } } @@ -397,7 +414,8 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) { // os.Root might return different errors depending on platform/implementation // but it definitely should error. // Our wrapper returns "access denied or file not found" - if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") && + if !strings.Contains(result.ForLLM, "access denied") && + !strings.Contains(result.ForLLM, "file not found") && !strings.Contains(result.ForLLM, "no such file") { t.Fatalf("expected symlink escape error, got: %s", result.ForLLM) } @@ -416,10 +434,20 @@ func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) { }) // We EXPECT IsError=true (access blocked due to empty workspace) - assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM) + assert.True( + t, + result.IsError, + "Security Regression: Empty workspace allowed access! content: %s", + result.ForLLM, + ) // Verify it failed for the right reason - assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error") + assert.Contains( + t, + result.ForLLM, + "workspace is not defined", + "Expected 'workspace is not defined' error", + ) } // TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases: @@ -653,7 +681,10 @@ func TestWhitelistFs_BlocksSymlinkEscapeInAllowedDir(t *testing.T) { patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))} tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns) - result := tool.Execute(context.Background(), map[string]any{"path": filepath.Join(linkPath, "secret.txt")}) + result := tool.Execute( + context.Background(), + map[string]any{"path": filepath.Join(linkPath, "secret.txt")}, + ) if !result.IsError { t.Fatalf("expected symlink escape from allowed dir to be blocked, got: %s", result.ForLLM) } @@ -726,7 +757,6 @@ func TestReadFileTool_ChunkedReading(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "pagination_test.txt") - // Create a test file with exactly 26 bytes of content fullContent := "abcdefghijklmnopqrstuvwxyz" err := os.WriteFile(testFile, []byte(fullContent), 0o644) if err != nil { @@ -748,15 +778,12 @@ func TestReadFileTool_ChunkedReading(t *testing.T) { t.Fatalf("Chunk 1 failed: %s", result1.ForLLM) } - // Expect the first 10 characters if !strings.Contains(result1.ForLLM, "abcdefghij") { t.Errorf("Chunk 1 should contain 'abcdefghij', got: %s", result1.ForLLM) } - // Expect the header to indicate the file is truncated if !strings.Contains(result1.ForLLM, "[TRUNCATED") { t.Errorf("Chunk 1 header should indicate truncation, got: %s", result1.ForLLM) } - // Expect the header to suggest the next offset (10) if !strings.Contains(result1.ForLLM, "offset=10") { t.Errorf("Chunk 1 header should suggest next offset=10, got: %s", result1.ForLLM) } @@ -773,17 +800,14 @@ func TestReadFileTool_ChunkedReading(t *testing.T) { t.Fatalf("Chunk 2 failed: %s", result2.ForLLM) } - // Expect the next 10 characters if !strings.Contains(result2.ForLLM, "klmnopqrst") { t.Errorf("Chunk 2 should contain 'klmnopqrst', got: %s", result2.ForLLM) } - // Expect the header to suggest the next offset (20) if !strings.Contains(result2.ForLLM, "offset=20") { t.Errorf("Chunk 2 header should suggest next offset=20, got: %s", result2.ForLLM) } // Step 3: Read the final chunk (remaining 6 bytes) --- - // We ask for 10 bytes, but only 6 are left in the file args3 := map[string]any{ "path": testFile, "offset": 20, @@ -795,16 +819,12 @@ func TestReadFileTool_ChunkedReading(t *testing.T) { t.Fatalf("Chunk 3 failed: %s", result3.ForLLM) } - // Expect the last 6 characters if !strings.Contains(result3.ForLLM, "uvwxyz") { t.Errorf("Chunk 3 should contain 'uvwxyz', got: %s", result3.ForLLM) } - // Expect the header to indicate the end of the file if !strings.Contains(result3.ForLLM, "[END OF FILE") { t.Errorf("Chunk 3 header should indicate end of file, got: %s", result3.ForLLM) } - - // Ensure no TRUNCATED message is present in the final chunk if strings.Contains(result3.ForLLM, "[TRUNCATED") { t.Errorf("Chunk 3 header should NOT indicate truncation, got: %s", result3.ForLLM) } @@ -816,7 +836,6 @@ func TestReadFileTool_OffsetBeyondEOF(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "short.txt") - // create a file of only 5 bytes err := os.WriteFile(testFile, []byte("12345"), 0o644) if err != nil { t.Fatalf("Failed to write test file: %v", err) @@ -827,19 +846,393 @@ func TestReadFileTool_OffsetBeyondEOF(t *testing.T) { args := map[string]any{ "path": testFile, - "offset": int64(100), // Offset beyond the end of the file + "offset": int64(100), } result := tool.Execute(ctx, args) - // It should not be classified as a tool execution error if result.IsError { t.Errorf("A mistake was not expected, obtained IsError=true: %s", result.ForLLM) } - // Must return EXACTLY the string provided in the code expectedMsg := "[END OF FILE - no content at this offset]" if result.ForLLM != expectedMsg { t.Errorf("The message %q was expected, obtained: %q", expectedMsg, result.ForLLM) } } + +func TestReadFileLinesTool_ChunkedReading(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "pagination_lines.txt") + + fullContent := strings.Join([]string{ + "line 1", + "line 2", + "line 3", + "line 4", + "line 5", + "line 6", + }, "\n") + "\n" + err := os.WriteFile(testFile, []byte(fullContent), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + + result1 := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + "max_lines": 2, + }) + if result1.IsError { + t.Fatalf("Chunk 1 failed: %s", result1.ForLLM) + } + if !strings.Contains(result1.ForLLM, "1|line 1\n2|line 2\n") { + t.Fatalf("expected first two lines, got: %s", result1.ForLLM) + } + if !strings.Contains(result1.ForLLM, "lines 1-2") { + t.Fatalf("expected line range 1-2, got: %s", result1.ForLLM) + } + if !strings.Contains(result1.ForLLM, "start_line=3") { + t.Fatalf("expected continuation start_line=3, got: %s", result1.ForLLM) + } + if !strings.Contains(result1.ForLLM, "max_lines=2") { + t.Fatalf("expected continuation max_lines=2, got: %s", result1.ForLLM) + } + + result2 := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 3, + "max_lines": 2, + }) + if result2.IsError { + t.Fatalf("Chunk 2 failed: %s", result2.ForLLM) + } + if !strings.Contains(result2.ForLLM, "3|line 3\n4|line 4\n") { + t.Fatalf("expected middle chunk, got: %s", result2.ForLLM) + } + if !strings.Contains(result2.ForLLM, "start_line=5") { + t.Fatalf("expected continuation start_line=5, got: %s", result2.ForLLM) + } + if !strings.Contains(result2.ForLLM, "max_lines=2") { + t.Fatalf("expected continuation max_lines=2, got: %s", result2.ForLLM) + } + + result3 := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 5, + "max_lines": 2, + }) + if result3.IsError { + t.Fatalf("Chunk 3 failed: %s", result3.ForLLM) + } + if !strings.Contains(result3.ForLLM, "5|line 5\n6|line 6\n") { + t.Fatalf("expected final chunk, got: %s", result3.ForLLM) + } + if !strings.Contains(result3.ForLLM, "[END OF FILE") { + t.Fatalf("expected EOF marker, got: %s", result3.ForLLM) + } +} + +func TestReadFileLinesTool_DefaultOffsetAndRemainingLines(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "default_lines.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + }) + if result.IsError { + t.Fatalf("Execute() error = %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "1|line 1\n2|line 2\n3|line 3\n") { + t.Fatalf("expected remaining lines by default, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "lines 1-3") { + t.Fatalf("expected line range 1-3, got: %s", result.ForLLM) + } +} + +func TestReadFileTool_LegacyLengthUsesByteModeForText(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "legacy_bytes.txt") + + err := os.WriteFile(testFile, []byte("abcdefghijklmnopqrstuvwxyz"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileBytesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "offset": 10, + "length": 5, + }) + if result.IsError { + t.Fatalf("Execute() error = %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "read: bytes 10-14") { + t.Fatalf("expected byte-based header, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "klmno") { + t.Fatalf("expected byte chunk content, got: %s", result.ForLLM) + } + if strings.Contains(result.ForLLM, "lines ") { + t.Fatalf("expected legacy byte mode, got line-based header: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_OffsetBeyondEOF(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "short_lines.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": int64(100), + }) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if result.ForLLM != "[END OF FILE - no content at or after start_line=100]" { + t.Fatalf("unexpected EOF message: %q", result.ForLLM) + } +} + +func TestReadFileLinesTool_RegistryValidationSupportsMaxLinesAndRejectsLimit(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "registry_lines.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + reg := NewToolRegistry() + reg.Register(NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)) + + result := reg.Execute(context.Background(), "read_file", map[string]any{ + "path": testFile, + "start_line": 1, + "max_lines": 1, + }) + if result.IsError { + t.Fatalf("expected max_lines to pass registry validation, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "1|line 1\n") { + t.Fatalf("expected first line via max_lines, got: %s", result.ForLLM) + } + + result = reg.Execute(context.Background(), "read_file", map[string]any{ + "path": testFile, + "start_line": 2, + "limit": 1, + }) + if !result.IsError { + t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "unexpected property \"limit\"") { + t.Fatalf("expected registry validation error for limit, got: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_RejectsOffset(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "legacy_offset.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + "offset": 1, + }) + if !result.IsError { + t.Fatalf("expected offset to be rejected, got success: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "offset is not supported in line mode; use start_line") { + t.Fatalf("unexpected error for offset in line mode: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_RejectsLength(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "legacy_length.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + "length": 1, + }) + if !result.IsError { + t.Fatalf("expected length to be rejected, got success: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "length is not supported in line mode; use max_lines") { + t.Fatalf("unexpected error for length in line mode: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_RejectsLimit(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "legacy_limit.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + "limit": 1, + }) + if !result.IsError { + t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "limit is not supported in line mode; use max_lines") { + t.Fatalf("unexpected error for limit in line mode: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_BinaryFileRejected(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "binary.dat") + + data := []byte{0x00, 0x01, 'A', 'B', 'C', 'D', 'E', 'F'} + err := os.WriteFile(testFile, data, 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + }) + if !result.IsError { + t.Fatalf("expected binary file rejection in line mode, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "switch read_file mode to 'bytes'") { + t.Fatalf("expected binary file rejection message, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "mode to 'bytes'") { + t.Fatalf("expected suggestion to switch read_file mode, got: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_TruncatesSingleLongLineAtByteBudget(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "long_line.txt") + + content := "first line\n" + strings.Repeat("x", 70*1024) + "\n" + err := os.WriteFile(testFile, []byte(content), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + }) + if result.IsError { + t.Fatalf("Execute() error = %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "was cut mid-line") { + t.Fatalf("expected explicit mid-line truncation warning, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "1|first line\n") { + t.Fatalf("expected the first line with line prefix, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "2|") { + t.Fatalf("expected line prefix for the truncated line, got: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_NoTrailingNewline(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "no_trailing_newline.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + }) + if result.IsError { + t.Fatalf("Execute() error = %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "1|line 1\n2|line 2") { + t.Fatalf( + "expected final line without trailing newline to be preserved, got: %s", + result.ForLLM, + ) + } + if !strings.Contains(result.ForLLM, "[END OF FILE - no further content.]") { + t.Fatalf("expected EOF marker, got: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_ExactByteBudgetBoundaryIncludesPrefix(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "exact_boundary.txt") + + err := os.WriteFile(testFile, []byte("1234567\nsecond line\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, 10) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + }) + if result.IsError { + t.Fatalf("Execute() error = %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "1|1234567\n") { + t.Fatalf( + "expected first line to fit exactly in the byte budget with its prefix, got: %s", + result.ForLLM, + ) + } + if strings.Contains(result.ForLLM, "2|") { + t.Fatalf( + "expected second line to be excluded once the exact output byte budget was reached, got: %s", + result.ForLLM, + ) + } + if !strings.Contains(result.ForLLM, "file_bytes: 8 | output_bytes: 10") { + t.Fatalf("expected separate file/output byte counters, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "start_line=2") { + t.Fatalf("expected continuation at line 2, got: %s", result.ForLLM) + } +}