restore old bytes read_file tool

This commit is contained in:
afjcjsbx 2026-03-27 21:25:07 +01:00
parent a731151d5e
commit 6090ab1d20
4 changed files with 516 additions and 669 deletions

View file

@ -293,8 +293,10 @@ Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous
| 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 in `bytes` mode and the output budget source for `lines` mode truncation |
| `tools.read_file.max_read_file_size` | int | `65536` | Maximum bytes returned by `read_file` and byte budget used by `read_file_lines` |
| `tools.read_file_lines.enabled` | bool | `false` | Enables the separate line-oriented `read_file_lines` tool |
#### Mode: `bytes`
@ -314,22 +316,21 @@ Use `bytes` when:
#### Mode: `lines`
Text-oriented behavior, optimized for source files, markdown, logs, and configs.
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
* `offset` (optional): Starting line number, 1-indexed, default `1`
* `limit` (optional): Maximum number of lines to read, default = all remaining lines
* `offset` (optional): Starting line number, 1-indexed and inclusive, default `0`
* `limit` (optional): Maximum number of lines to read, default = all remaining lines until EOF or byte budget
When content exceeds the estimated token budget, PicoClaw truncates it intelligently:
Behavior notes:
* Estimates the token count heuristically
* Keeps the head and tail of the selected text
* Cuts on newline boundaries when possible
* Inserts an explicit truncation notice in the middle
* Binary-looking files are rejected with guidance to use `read_file`
* Extremely long single lines are truncated rather than skipped
* If truncation happens mid-line, the tool explicitly suggests falling back to `read_file` for byte-wise inspection
Use `lines` when:
Use `read_file_lines` when:
* The agent mostly reads text files
* You want line-based pagination in prompts and tool calls
@ -342,9 +343,11 @@ Use `lines` when:
"tools": {
"read_file": {
"enabled": true,
"mode": "lines",
"max_read_file_size": 65536
}
},
"read_file_lines": {
"enabled": true
}
}
}
```

View file

@ -1,281 +0,0 @@
# ⚙️ Guida alla Configurazione
> Torna al [README](../../README.md)
## ⚙️ Configurazione
File di configurazione: `~/.picoclaw/config.json`
### Variabili d'Ambiente
Puoi sovrascrivere i percorsi predefiniti usando variabili d'ambiente. Questo è utile per installazioni portatili, distribuzioni containerizzate, o per eseguire picoclaw come servizio di sistema. Queste variabili sono indipendenti e controllano percorsi diversi.
| Variabile | Descrizione | Percorso Predefinito |
|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------|
| `PICOCLAW_CONFIG` | Sovrascrive il percorso al file di configurazione. Indica direttamente a picoclaw quale `config.json` caricare, ignorando tutte le altre posizioni. | `~/.picoclaw/config.json` |
| `PICOCLAW_HOME` | Sovrascrive la directory radice per i dati di picoclaw. Modifica la posizione predefinita del `workspace` e delle altre directory dati. | `~/.picoclaw` |
**Esempi:**
```bash
# Esegui picoclaw usando un file di configurazione specifico
# Il percorso del workspace verrà letto da quel file di configurazione
PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway
# Esegui picoclaw con tutti i dati salvati in /opt/picoclaw
# La configurazione verrà caricata dal percorso predefinito ~/.picoclaw/config.json
# Il workspace verrà creato in /opt/picoclaw/workspace
PICOCLAW_HOME=/opt/picoclaw picoclaw agent
# Usa entrambi per un setup completamente personalizzato
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
```
### Struttura del Workspace
PicoClaw salva i dati nel workspace configurato (predefinito: `~/.picoclaw/workspace`):
```
~/.picoclaw/workspace/
├── sessions/ # Sessioni di conversazione e cronologia
├── memory/ # Memoria a lungo termine (MEMORY.md)
├── state/ # Stato persistente (ultimo canale, ecc.)
├── cron/ # Database dei job pianificati
├── skills/ # Skill personalizzate
├── AGENTS.md # Guida al comportamento dell'agent
├── HEARTBEAT.md # Prompt per task periodici (controllato ogni 30 min)
├── IDENTITY.md # Identità dell'agent
├── SOUL.md # Anima dell'agent
└── USER.md # Preferenze dell'utente
```
> **Nota:** Le modifiche a `AGENTS.md`, `SOUL.md`, `USER.md`, `IDENTITY.md` e `memory/MEMORY.md` vengono rilevate automaticamente a runtime tramite il tracciamento della data di modifica (mtime). **Non è necessario riavviare il gateway** dopo aver modificato questi file — l'agent caricherà il nuovo contenuto alla prossima richiesta.
### Sorgenti delle Skill
Per impostazione predefinita, le skill vengono caricate da:
1. `~/.picoclaw/workspace/skills` (workspace)
2. `~/.picoclaw/skills` (globale)
3. `<current-working-directory>/skills` (builtin)
Per configurazioni avanzate/di test, puoi sovrascrivere la directory radice delle skill builtin con:
```bash
export PICOCLAW_BUILTIN_SKILLS=/path/to/skills
```
### Politica Unificata di Esecuzione dei Comandi
- I comandi slash generici vengono eseguiti tramite un unico percorso in `pkg/agent/loop.go` via `commands.Executor`.
- Gli adattatori dei canali non consumano più localmente i comandi generici; inoltrano il testo in entrata al percorso bus/agent. Telegram registra ancora automaticamente i comandi supportati all'avvio.
- Un comando slash sconosciuto (ad esempio `/foo`) viene passato all'elaborazione LLM come se fosse un messaggio dell'utente.
- Un comando registrato ma non supportato sul canale corrente (ad esempio `/show` su WhatsApp) restituisce un errore esplicito all'utente e interrompe l'elaborazione.
### 🔒 Sandbox di Sicurezza
PicoClaw esegue in un ambiente sandboxed per impostazione predefinita. L'agent può accedere solo ai file ed eseguire comandi all'interno del workspace configurato.
#### Configurazione Predefinita
```json
{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true
}
}
}
```
| Opzione | Predefinito | Descrizione |
| ----------------------- | ----------------------- | ---------------------------------------------------- |
| `workspace` | `~/.picoclaw/workspace` | Directory di lavoro dell'agent |
| `restrict_to_workspace` | `true` | Limita l'accesso a file/comandi al workspace |
#### Strumenti Protetti
Quando `restrict_to_workspace: true`, i seguenti strumenti sono in sandbox:
| Strumento | Funzione | Restrizione |
| ------------- | ------------------------- | ---------------------------------------------------- |
| `read_file` | Legge file | Solo file all'interno del workspace |
| `write_file` | Scrive file | Solo file all'interno del workspace |
| `list_dir` | Elenca directory | Solo directory all'interno del workspace |
| `edit_file` | Modifica file | Solo file all'interno del workspace |
| `append_file` | Aggiunge ai file | Solo file all'interno del workspace |
| `exec` | Esegue comandi | I percorsi dei comandi devono essere nel workspace |
#### Protezione Exec Aggiuntiva
Anche con `restrict_to_workspace: false`, lo strumento `exec` blocca questi comandi pericolosi:
* `rm -rf`, `del /f`, `rmdir /s` — Cancellazione di massa
* `format`, `mkfs`, `diskpart` — Formattazione del disco
* `dd if=` — Imaging del disco
* Scrittura su `/dev/sd[a-z]` — Scritture dirette su disco
* `shutdown`, `reboot`, `poweroff` — Spegnimento del sistema
* Fork bomb `:(){ :|:& };:`
### Controllo Accesso ai File
| Chiave di configurazione | Tipo | Predefinito | Descrizione |
|--------------------------|------|-------------|-------------|
| `tools.allow_read_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la lettura al di fuori del workspace |
| `tools.allow_write_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la scrittura al di fuori del workspace |
### Modalità Read File
`read_file` ha due implementazioni mutuamente esclusive, selezionate via configurazione. PicoClaw ne registra esattamente una all'avvio:
| Chiave di configurazione | Tipo | Predefinito | Descrizione |
|--------------------------|------|-------------|-------------|
| `tools.read_file.mode` | string | `bytes` | Seleziona l'implementazione di `read_file`: `bytes` oppure `lines` |
| `tools.read_file.max_read_file_size` | int | `65536` | Numero massimo di byte restituiti in modalità `bytes` e base del budget di output per la truncation in modalità `lines` |
#### Modalità: `bytes`
Comportamento legacy, ottimizzato per file arbitrari e paginazione byte-safe.
Parametri:
* `path` (obbligatorio): percorso del file
* `offset` (opzionale): offset iniziale in byte, default `0`
* `length` (opzionale): numero massimo di byte da leggere, default `max_read_file_size`
Usa `bytes` quando:
* Ti serve retrocompatibilità con prompt o agent esistenti
* Potresti leggere file binari
* Vuoi una paginazione deterministica a byte
#### Modalità: `lines`
Comportamento orientato al testo, ottimizzato per sorgenti, markdown, log e file di configurazione.
Parametri:
* `path` (obbligatorio): percorso del file
* `offset` (opzionale): numero di riga iniziale, 1-indexed, default `1`
* `limit` (opzionale): numero massimo di righe da leggere, default = tutte le righe rimanenti
Quando il contenuto supera il budget token stimato, PicoClaw applica una truncation intelligente:
* Stima il numero di token in modo euristico
* Mantiene testa e coda del testo selezionato
* Tronca, quando possibile, sui boundary di newline
* Inserisce al centro un avviso esplicito di contenuto troncato
Usa `lines` quando:
* L'agent legge soprattutto file testuali
* Vuoi paginazione a righe nei prompt e nei tool call
* Vuoi chunk più leggibili per code review, log e documentazione
#### Esempio
```json
{
"tools": {
"read_file": {
"enabled": true,
"mode": "lines",
"max_read_file_size": 65536
}
}
}
```
### Sicurezza Exec
| Chiave di configurazione | Tipo | Predefinito | Descrizione |
|--------------------------|------|-------------|-------------|
| `tools.exec.allow_remote` | bool | `false` | Consente lo strumento exec da canali remoti (Telegram/Discord ecc.) |
| `tools.exec.enable_deny_patterns` | bool | `true` | Abilita l'intercettazione dei comandi pericolosi |
| `tools.exec.custom_deny_patterns` | string[] | `[]` | Pattern regex personalizzati da bloccare |
| `tools.exec.custom_allow_patterns` | string[] | `[]` | Pattern regex personalizzati da consentire |
> **Nota di sicurezza:** La protezione dei symlink è abilitata per impostazione predefinita — tutti i percorsi file vengono risolti tramite `filepath.EvalSymlinks` prima del confronto con la whitelist, prevenendo attacchi di escape tramite symlink.
#### Limitazione Nota: Processi Figlio degli Strumenti di Build
Il controllo di sicurezza exec ispeziona solo la riga di comando avviata direttamente da PicoClaw. Non ispeziona ricorsivamente i processi figlio generati da strumenti di sviluppo consentiti come `make`, `go run`, `cargo`, `npm run` o script di build personalizzati.
Ciò significa che un comando di primo livello può comunque compilare o avviare altri binari dopo aver superato il controllo iniziale. In pratica, tratta gli script di build, i Makefile, gli script di pacchetti e i binari generati come codice eseguibile che richiede lo stesso livello di revisione di un comando shell diretto.
Per ambienti ad alto rischio:
* Esamina gli script di build prima dell'esecuzione.
* Preferisci l'approvazione/revisione manuale per i workflow di compilazione ed esecuzione.
* Esegui PicoClaw in un container o VM se hai bisogno di un isolamento più forte di quello fornito dal controllo integrato.
#### Esempi di Errore
```
[ERROR] tool: Tool execution failed
{tool=exec, error=Command blocked by safety guard (path outside working dir)}
```
```
[ERROR] tool: Tool execution failed
{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
```
#### Disabilitare le Restrizioni (Rischio di Sicurezza)
Se hai bisogno che l'agent acceda a percorsi al di fuori del workspace:
**Metodo 1: File di configurazione**
```json
{
"agents": {
"defaults": {
"restrict_to_workspace": false
}
}
}
```
**Metodo 2: Variabile d'ambiente**
```bash
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
```
> ⚠️ **Attenzione**: Disabilitare questa restrizione consente all'agent di accedere a qualsiasi percorso sul tuo sistema. Usare con cautela solo in ambienti controllati.
#### Coerenza dei Confini di Sicurezza
L'impostazione `restrict_to_workspace` si applica in modo coerente a tutti i percorsi di esecuzione:
| Percorso di esecuzione | Confine di sicurezza |
| ---------------------- | --------------------------------- |
| Main Agent | `restrict_to_workspace` ✅ |
| Subagent / Spawn | Eredita la stessa restrizione ✅ |
| Heartbeat tasks | Eredita la stessa restrizione ✅ |
Tutti i percorsi condividono la stessa restrizione del workspace — non è possibile aggirare il confine di sicurezza tramite subagent o task pianificati.
### Heartbeat (Task Periodici)
PicoClaw può eseguire task periodici automaticamente. Crea un file `HEARTBEAT.md` nel tuo workspace:
```markdown
# Periodic Tasks
- Check my email for important messages
- Review my calendar for upcoming events
- Check the weather forecast
```
L'agent leggerà questo file ogni 30 minuti (configurabile) ed eseguirà tutti i task usando gli strumenti disponibili.
#### Task Asincroni con Spawn
Per task di lunga durata (ricerca web, chiamate API), usa lo strumento `spawn` per creare un **subagent**:
```markdown
# Periodic Tasks
```

View file

@ -4,7 +4,9 @@ import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"math"
"net/http"
@ -18,13 +20,10 @@ import (
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/routing"
)
const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow
const readFileTruncationSafetyMargin = 0.95
func validatePathWithAllowPaths(path, workspace string, restrict bool, patterns []*regexp.Regexp) (string, error) {
if workspace == "" {
return path, fmt.Errorf("workspace is not defined")
@ -254,14 +253,11 @@ func isWithinWorkspace(candidate, workspace string) bool {
}
type ReadFileTool struct {
readFileBase
fs fileSystem
maxSize int64
}
type ReadFileLinesTool struct {
readFileBase
}
type readFileBase struct {
fs fileSystem
maxSize int64
}
@ -271,15 +267,6 @@ func NewReadFileTool(
restrict bool,
maxReadFileSize int,
allowPaths ...[]*regexp.Regexp,
) *ReadFileTool {
return NewReadFileBytesTool(workspace, restrict, maxReadFileSize, allowPaths...)
}
func NewReadFileBytesTool(
workspace string,
restrict bool,
maxReadFileSize int,
allowPaths ...[]*regexp.Regexp,
) *ReadFileTool {
var patterns []*regexp.Regexp
if len(allowPaths) > 0 {
@ -292,13 +279,20 @@ func NewReadFileBytesTool(
}
return &ReadFileTool{
readFileBase: readFileBase{
fs: buildFs(workspace, restrict, patterns),
maxSize: maxSize,
},
fs: buildFs(workspace, restrict, patterns),
maxSize: maxSize,
}
}
func NewReadFileBytesTool(
workspace string,
restrict bool,
maxReadFileSize int,
allowPaths ...[]*regexp.Regexp,
) *ReadFileTool {
return NewReadFileTool(workspace, restrict, maxReadFileSize, allowPaths...)
}
func NewReadFileLinesTool(
workspace string,
restrict bool,
@ -316,10 +310,8 @@ func NewReadFileLinesTool(
}
return &ReadFileLinesTool{
readFileBase: readFileBase{
fs: buildFs(workspace, restrict, patterns),
maxSize: maxSize,
},
fs: buildFs(workspace, restrict, patterns),
maxSize: maxSize,
}
}
@ -328,15 +320,15 @@ func (t *ReadFileTool) Name() string {
}
func (t *ReadFileLinesTool) Name() string {
return "read_file"
return "read_file_lines"
}
func (t *ReadFileTool) Description() string {
return "Read the contents of a file using byte-based pagination via `offset` and `length`."
return "Read the contents of a file. Supports pagination via `offset` and `length`."
}
func (t *ReadFileLinesTool) Description() string {
return "Read file contents from the filesystem. Output always includes line numbers in the format `LINE_NUMBER|LINE_CONTENT` (1-indexed). Supports partial reads via `offset` and `limit` for large text files."
return "Read a UTF-8 text file from a specific line range. Uses 1-indexed line offsets and stops when the configured byte budget is reached."
}
func (t *ReadFileTool) Parameters() map[string]any {
@ -370,14 +362,14 @@ func (t *ReadFileLinesTool) Parameters() map[string]any {
"type": "string",
"description": "Path to the file to read.",
},
"offset": map[string]any{
"start_line": map[string]any{
"type": "integer",
"description": "Starting line number (1-indexed). Use for large files to read from a specific line.",
"description": "Line number to start reading from (1-indexed, inclusive).",
"default": 1,
},
"limit": map[string]any{
"max_lines": map[string]any{
"type": "integer",
"description": "Number of lines to read. Use with offset for large files to read in chunks.",
"description": "Maximum number of lines to read.",
},
},
"required": []string{"path"},
@ -390,114 +382,7 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe
return ErrorResult("path is required")
}
data, err := t.fs.ReadFile(path)
if err != nil {
return ErrorResult(err.Error())
}
return t.executeByteRead(path, data, args)
}
func (t *ReadFileLinesTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
return ErrorResult("path is required")
}
data, err := t.fs.ReadFile(path)
if err != nil {
return ErrorResult(err.Error())
}
if isBinaryReadFileData(data) {
return ErrorResult(`file appears to be binary`)
}
return t.executeLineRead(path, data, args)
}
func (t *readFileBase) executeLineRead(path string, data []byte, args map[string]any) *ToolResult {
offset, err := getInt64Arg(args, "offset", 1)
if err != nil {
return ErrorResult(err.Error())
}
if offset < 1 {
return ErrorResult("offset must be >= 1")
}
limit := int64(-1)
if _, exists := args["limit"]; exists {
limit, err = getInt64Arg(args, "limit", -1)
if err != nil {
return ErrorResult(err.Error())
}
if limit <= 0 {
return ErrorResult("limit must be > 0")
}
}
lines := splitLinesPreserveNewlines(string(data))
totalLines := int64(len(lines))
if totalLines == 0 {
return NewToolResult("[END OF FILE - no content at this offset]")
}
startIdx := offset - 1
if startIdx >= totalLines {
return NewToolResult("[END OF FILE - no content at this offset]")
}
endExclusive := totalLines
if limit > 0 {
endExclusive = startIdx + limit
if endExclusive > totalLines {
endExclusive = totalLines
}
}
selectedLines := lines[startIdx:endExclusive]
if len(selectedLines) == 0 {
return NewToolResult("[END OF FILE - no content at this offset]")
}
formatted := make([]string, 0, len(lines))
for _, line := range lines {
// Remove only final carriage returns to normalize
lineContent := strings.TrimRight(line, "\r\n")
formatted = append(formatted, lineContent)
}
content := strings.Join(formatted, "\n")
maxTokens := t.maxTokenBudget()
content = truncateReadFileContent(content, maxTokens)
readEndLine := startIdx + int64(len(selectedLines))
readRange := fmt.Sprintf("lines %d-%d", offset, readEndLine)
displayPath := filepath.Base(path)
header := fmt.Sprintf(
"[file: %s | total: %d lines | read: %s]",
displayPath, totalLines, readRange,
)
hasMore := endExclusive < totalLines
if hasMore {
header += fmt.Sprintf(
"\n[TRUNCATED - file has more content. Call read_file again with offset=%d to continue.]",
readEndLine+1,
)
}
logger.DebugCF("tool", "ReadFileTool execution completed successfully",
map[string]any{
"path": path,
"lines_read": len(selectedLines),
"has_more": hasMore,
})
return NewToolResult(header + "\n\n" + content)
}
func (t *readFileBase) executeByteRead(path string, data []byte, args map[string]any) *ToolResult {
// offset (optional, default 0)
offset, err := getInt64Arg(args, "offset", 0)
if err != nil {
return ErrorResult(err.Error())
@ -506,6 +391,7 @@ func (t *readFileBase) executeByteRead(path string, data []byte, args map[string
return ErrorResult("offset must be >= 0")
}
// length (optional, capped at MaxReadFileSize)
length, err := getInt64Arg(args, "length", t.maxSize)
if err != nil {
return ErrorResult(err.Error())
@ -517,28 +403,105 @@ func (t *readFileBase) executeByteRead(path string, data []byte, args map[string
length = t.maxSize
}
totalSize := int64(len(data))
if offset >= totalSize {
file, err := t.fs.Open(path)
if err != nil {
return ErrorResult(err.Error())
}
defer file.Close()
// measure total size
totalSize := int64(-1) // -1 means unknown
if info, statErr := file.Stat(); statErr == nil {
totalSize = info.Size()
}
// sniff the first 512 bytes to detect binary content before loading
// it into the LLM context. Seeking back to 0 afterwards restores state.
sniff := make([]byte, 512)
sniffN, _ := file.Read(sniff)
// Reset read position to beginning before applying the caller's offset.
if seeker, ok := file.(io.Seeker); ok {
_, err = seeker.Seek(0, io.SeekStart)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to reset file position after sniff: %v", err))
}
} else {
// Non-seekable: we consumed sniffN bytes above; account for them when
// discarding to reach the requested offset below.
// If offset < sniffN the data we already read covers it, which we
// cannot replay on a non-seekable stream — return a clear error.
if offset < int64(sniffN) && offset > 0 {
return ErrorResult(
"non-seekable file: cannot seek to an offset within the first 512 bytes after binary detection",
)
}
}
// Seek to the requested offset.
if seeker, ok := file.(io.Seeker); ok {
_, err = seeker.Seek(offset, io.SeekStart)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to seek to offset %d: %v", offset, err))
}
} else if offset > 0 {
// Fallback for non-seekable streams: discard leading bytes.
// sniffN bytes were already consumed above, so subtract them.
remaining := offset - int64(sniffN)
if remaining > 0 {
_, err = io.CopyN(io.Discard, file, remaining)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to advance to offset %d: %v", offset, err))
}
}
}
// read length+1 bytes to reliably detect whether more content exists
// without relying on totalSize (which may be -1 for non-seekable streams).
// This avoids the false-positive TRUNCATED message on the last page.
probe := make([]byte, length+1)
n, err := io.ReadFull(file, probe)
// FIX: io.ReadFull returns io.ErrUnexpectedEOF for partial reads (0 < n < len),
// and io.EOF only when n == 0. Both are normal terminal conditions — only
// other errors are genuine failures.
if err != nil && err != io.EOF && !errors.Is(err, io.ErrUnexpectedEOF) {
return ErrorResult(fmt.Sprintf("failed to read file content: %v", err))
}
// hasMore is true only when we actually got the extra probe byte.
hasMore := int64(n) > length
data := probe[:min(int64(n), length)]
if len(data) == 0 {
return NewToolResult("[END OF FILE - no content at this offset]")
}
end := offset + length
if end > totalSize {
end = totalSize
}
chunk := data[offset:end]
readRange := fmt.Sprintf("bytes %d-%d", offset, end-1)
displayPath := filepath.Base(path)
header := fmt.Sprintf(
"[file: %s | total: %d bytes | read: %s]",
displayPath, totalSize, readRange,
)
// Build metadata header.
// use filepath.Base(path) instead of the raw path to avoid leaking
// internal filesystem structure into the LLM context.
readEnd := offset + int64(len(data))
// use ASCII hyphen-minus instead of en-dash (U+2013) to keep the
// header parseable by downstream tools and log processors.
readRange := fmt.Sprintf("bytes %d-%d", offset, readEnd-1)
displayPath := filepath.Base(path)
var header string
if totalSize >= 0 {
header = fmt.Sprintf(
"[file: %s | total: %d bytes | read: %s]",
displayPath, totalSize, readRange,
)
} else {
header = fmt.Sprintf(
"[file: %s | read: %s | total size unknown]",
displayPath, readRange,
)
}
hasMore := end < totalSize
if hasMore {
header += fmt.Sprintf(
"\n[TRUNCATED - file has more content. Call read_file again with offset=%d to continue.]",
end,
readEnd,
)
} else {
header += "\n[END OF FILE - no further content.]"
@ -547,36 +510,162 @@ func (t *readFileBase) executeByteRead(path string, data []byte, args map[string
logger.DebugCF("tool", "ReadFileTool execution completed successfully",
map[string]any{
"path": path,
"bytes_read": len(chunk),
"bytes_read": len(data),
"has_more": hasMore,
"mode": "bytes",
})
return NewToolResult(header + "\n\n" + string(chunk))
return NewToolResult(header + "\n\n" + string(data))
}
func (t *readFileBase) maxTokenBudget() int {
if t.maxSize <= 0 {
return MaxReadFileSize / 4
func (t *ReadFileLinesTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
path, ok := args["path"].(string)
if !ok {
return ErrorResult("path is required")
}
budget := int(t.maxSize / 4)
if budget <= 0 {
return 1
startLine, err := getInt64Arg(args, "start_line", 0)
if err != nil {
return ErrorResult(err.Error())
}
return budget
}
func splitLinesPreserveNewlines(text string) []string {
if text == "" {
return nil
if startLine < 1 {
return ErrorResult("offset must be >= 1")
}
lines := strings.SplitAfter(text, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
limit := int64(-1)
if raw, exists := args["limit"]; exists && raw != nil {
limit, err = getInt64Arg(args, "limit", -1)
if err != nil {
return ErrorResult(err.Error())
}
if limit <= 0 {
return ErrorResult("limit, if provided, must be > 0")
}
}
return lines
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; use read_file for byte-based inspection")
}
reader := bufio.NewReaderSize(io.MultiReader(bytes.NewReader(sample), file), 32*1024)
var content strings.Builder
var lineIndex int64
var linesRead int64
var bytesRead 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) {
remaining := t.maxSize - bytesRead
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.Write(line)
bytesRead += int64(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("[END OF FILE - no content at this offset]")
}
start := startLine
endLine := startLine + linesRead - 1
displayPath := filepath.Base(path)
header := fmt.Sprintf(
"[file: %s | read: lines %d-%d (0-indexed) | bytes: %d]",
displayPath, start, endLine, bytesRead,
)
switch {
case lineTruncated:
header += fmt.Sprintf(
"\n[TRUNCATED - line %d exceeded the %d byte read budget and was cut mid-line. Use read_file for byte-wise inspection of the remaining content.]",
endLine,
t.maxSize,
)
case byteBudgetTruncated:
header += fmt.Sprintf(
"\n[TRUNCATED - byte budget reached. Call read_file_lines again with offset=%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_lines again with offset=%d to continue.]",
startLine+linesRead,
)
default:
header += "\n[END OF FILE - no further content.]"
}
logger.DebugCF("tool", "ReadFileTool execution completed successfully",
map[string]any{
"path": path,
"lines_read": linesRead,
"bytes_read": bytesRead,
"truncated": byteBudgetTruncated,
"tool": t.Name(),
})
return NewToolResult(header + "\n\n" + content.String())
}
func isBinaryReadFileData(data []byte) bool {
@ -619,119 +708,82 @@ func isBinaryReadFileData(data []byte) bool {
return float64(controlChars)/float64(len(sample)) > 0.1
}
func truncateReadFileContent(text string, maxTokens int) string {
if strings.TrimSpace(text) == "" || maxTokens <= 0 {
return text
}
func consumeNextLine(reader *bufio.Reader) (bool, error) {
sawData := false
tokenCount := routing.EstimateTokens(text)
if tokenCount <= maxTokens {
return text
}
totalChars := utf8.RuneCountInString(text)
if totalChars == 0 {
return text
}
tokenPerChar := float64(tokenCount) / float64(totalChars)
if tokenPerChar <= 0 {
return text
}
allowedChars := int(float64(maxTokens)/tokenPerChar*readFileTruncationSafetyMargin + 0.5)
if allowedChars < 2 {
allowedChars = 2
}
if allowedChars >= totalChars {
return text
}
headChars := allowedChars / 2
tailChars := allowedChars - headChars
headRaw := firstNRunes(text, headChars)
tailRaw := lastNRunes(text, tailChars)
head := trimHeadToLineBoundary(headRaw)
tail := trimTailToLineBoundary(tailRaw)
if head == "" {
head = strings.TrimRight(headRaw, "\n")
}
if tail == "" {
tail = strings.TrimLeft(tailRaw, "\n")
}
notice := fmt.Sprintf(
"\n\n... [Content truncated: %d tokens -> ~%d tokens limit] ...\n\n",
tokenCount,
maxTokens,
)
switch {
case head == "" && tail == "":
return notice
case head == "":
return notice + tail
case tail == "":
return head + notice
default:
return head + notice + tail
}
}
func firstNRunes(text string, n int) string {
if n <= 0 {
return ""
}
if utf8.RuneCountInString(text) <= n {
return text
}
var builder strings.Builder
builder.Grow(n)
count := 0
reader := bufio.NewReader(strings.NewReader(text))
for count < n {
r, _, err := reader.ReadRune()
if err != nil {
break
for {
fragment, err := reader.ReadSlice('\n')
if len(fragment) > 0 {
sawData = true
}
builder.WriteRune(r)
count++
}
return builder.String()
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 lastNRunes(text string, n int) string {
if n <= 0 {
return ""
func readNextLinePrefix(reader *bufio.Reader, maxBytes int64) ([]byte, bool, bool, error) {
if maxBytes <= 0 {
return nil, false, false, nil
}
runes := []rune(text)
if len(runes) <= n {
return text
}
var out bytes.Buffer
sawData := false
complete := true
return string(runes[len(runes)-n:])
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 trimHeadToLineBoundary(text string) string {
idx := strings.LastIndex(text, "\n")
if idx < 0 {
return strings.TrimRight(text, "\n")
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
}
return strings.TrimRight(text[:idx], "\n")
}
func trimTailToLineBoundary(text string) string {
idx := strings.Index(text, "\n")
if idx < 0 {
return strings.TrimLeft(text, "\n")
}
return strings.TrimLeft(text[idx+1:], "\n")
}
// getInt64Arg extracts an integer argument from the args map, returning the

View file

@ -6,7 +6,6 @@ import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
@ -60,7 +59,7 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) {
}
// Should contain error message
if !strings.Contains(result.ForLLM, "failed to read file") && !strings.Contains(result.ForUser, "failed to read") {
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)
}
}
@ -722,11 +721,119 @@ func TestWhitelistFs_AllowsResolvedAllowedRootAlias(t *testing.T) {
}
// TestReadFileTool_ChunkedReading verifies the pagination logic of the tool
// by reading a file in multiple chunks using 1-indexed line offset and limit.
// by reading a file in multiple chunks using 'offset' and 'length'.
func TestReadFileTool_ChunkedReading(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "pagination_test.txt")
fullContent := "abcdefghijklmnopqrstuvwxyz"
err := os.WriteFile(testFile, []byte(fullContent), 0o644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
tool := NewReadFileTool(tmpDir, false, MaxReadFileSize)
ctx := context.Background()
// --- Step 1: Read the first chunk (10 bytes) ---
args1 := map[string]any{
"path": testFile,
"offset": 0,
"length": 10,
}
result1 := tool.Execute(ctx, args1)
if result1.IsError {
t.Fatalf("Chunk 1 failed: %s", result1.ForLLM)
}
if !strings.Contains(result1.ForLLM, "abcdefghij") {
t.Errorf("Chunk 1 should contain 'abcdefghij', got: %s", result1.ForLLM)
}
if !strings.Contains(result1.ForLLM, "[TRUNCATED") {
t.Errorf("Chunk 1 header should indicate truncation, got: %s", result1.ForLLM)
}
if !strings.Contains(result1.ForLLM, "offset=10") {
t.Errorf("Chunk 1 header should suggest next offset=10, got: %s", result1.ForLLM)
}
// Step 2: Read the second chunk (10 bytes) ---
args2 := map[string]any{
"path": testFile,
"offset": 10,
"length": 10,
}
result2 := tool.Execute(ctx, args2)
if result2.IsError {
t.Fatalf("Chunk 2 failed: %s", result2.ForLLM)
}
if !strings.Contains(result2.ForLLM, "klmnopqrst") {
t.Errorf("Chunk 2 should contain 'klmnopqrst', got: %s", result2.ForLLM)
}
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) ---
args3 := map[string]any{
"path": testFile,
"offset": 20,
"length": 10,
}
result3 := tool.Execute(ctx, args3)
if result3.IsError {
t.Fatalf("Chunk 3 failed: %s", result3.ForLLM)
}
if !strings.Contains(result3.ForLLM, "uvwxyz") {
t.Errorf("Chunk 3 should contain 'uvwxyz', got: %s", result3.ForLLM)
}
if !strings.Contains(result3.ForLLM, "[END OF FILE") {
t.Errorf("Chunk 3 header should indicate end of file, got: %s", result3.ForLLM)
}
if strings.Contains(result3.ForLLM, "[TRUNCATED") {
t.Errorf("Chunk 3 header should NOT indicate truncation, got: %s", result3.ForLLM)
}
}
// TestReadFileTool_OffsetBeyondEOF checks the behavior when requesting
// An offset that exceeds the total file size.
func TestReadFileTool_OffsetBeyondEOF(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "short.txt")
err := os.WriteFile(testFile, []byte("12345"), 0o644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
tool := NewReadFileTool(tmpDir, false, MaxReadFileSize)
ctx := context.Background()
args := map[string]any{
"path": testFile,
"offset": int64(100),
}
result := tool.Execute(ctx, args)
if result.IsError {
t.Errorf("A mistake was not expected, obtained IsError=true: %s", result.ForLLM)
}
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",
@ -741,106 +848,57 @@ func TestReadFileTool_ChunkedReading(t *testing.T) {
}
tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
ctx := context.Background()
// --- Step 1: Read the first chunk (2 lines) ---
args1 := map[string]any{
result1 := tool.Execute(context.Background(), map[string]any{
"path": testFile,
"offset": 1,
"offset": 0,
"limit": 2,
}
result1 := tool.Execute(ctx, args1)
})
if result1.IsError {
t.Fatalf("Chunk 1 failed: %s", result1.ForLLM)
}
if !strings.Contains(result1.ForLLM, "line 1\nline 2\nline 3\nline 4\nline 5\nline 6") {
t.Errorf("Chunk 1 should contain file content, got: %s", result1.ForLLM)
if !strings.Contains(result1.ForLLM, "line 1\nline 2\n") {
t.Fatalf("expected first two lines, got: %s", result1.ForLLM)
}
if !strings.Contains(result1.ForLLM, "[TRUNCATED") {
t.Errorf("Chunk 1 header should indicate truncation, got: %s", result1.ForLLM)
if !strings.Contains(result1.ForLLM, "lines 0-1") {
t.Fatalf("expected line range 0-1, got: %s", result1.ForLLM)
}
if !strings.Contains(result1.ForLLM, "offset=3") {
t.Errorf("Chunk 1 header should suggest next offset=3, got: %s", result1.ForLLM)
}
if !strings.Contains(result1.ForLLM, "read: lines 1-2") {
t.Errorf("Chunk 1 header should report line range 1-2, got: %s", result1.ForLLM)
if !strings.Contains(result1.ForLLM, "offset=2") {
t.Fatalf("expected continuation offset=2, got: %s", result1.ForLLM)
}
// Step 2: Read the second chunk (2 lines) ---
args2 := map[string]any{
result2 := tool.Execute(context.Background(), map[string]any{
"path": testFile,
"offset": 3,
"offset": 2,
"limit": 2,
}
result2 := tool.Execute(ctx, args2)
})
if result2.IsError {
t.Fatalf("Chunk 2 failed: %s", result2.ForLLM)
}
if !strings.Contains(result2.ForLLM, "line 1\nline 2\nline 3\nline 4\nline 5\nline 6") {
t.Errorf("Chunk 2 should contain file content, got: %s", result2.ForLLM)
if !strings.Contains(result2.ForLLM, "line 3\nline 4\n") {
t.Fatalf("expected middle chunk, got: %s", result2.ForLLM)
}
if !strings.Contains(result2.ForLLM, "offset=5") {
t.Errorf("Chunk 2 header should suggest next offset=5, got: %s", result2.ForLLM)
if !strings.Contains(result2.ForLLM, "offset=4") {
t.Fatalf("expected continuation offset=4, got: %s", result2.ForLLM)
}
// Step 3: Read the final chunk (remaining 2 lines) ---
args3 := map[string]any{
result3 := tool.Execute(context.Background(), map[string]any{
"path": testFile,
"offset": 5,
"offset": 4,
"limit": 2,
}
result3 := tool.Execute(ctx, args3)
})
if result3.IsError {
t.Fatalf("Chunk 3 failed: %s", result3.ForLLM)
}
if !strings.Contains(result3.ForLLM, "line 1\nline 2\nline 3\nline 4\nline 5\nline 6") {
t.Errorf("Chunk 3 should contain file content, got: %s", result3.ForLLM)
if !strings.Contains(result3.ForLLM, "line 5\nline 6\n") {
t.Fatalf("expected final chunk, got: %s", result3.ForLLM)
}
if strings.Contains(result3.ForLLM, "[TRUNCATED") {
t.Errorf("Chunk 3 header should NOT indicate truncation, got: %s", result3.ForLLM)
if !strings.Contains(result3.ForLLM, "[END OF FILE") {
t.Fatalf("expected EOF marker, got: %s", result3.ForLLM)
}
}
// TestReadFileTool_OffsetBeyondEOF checks the behavior when requesting
// a starting line that exceeds the total number of lines.
func TestReadFileTool_OffsetBeyondEOF(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "short.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)
ctx := context.Background()
args := map[string]any{
"path": testFile,
"offset": int64(100), // Line offset beyond the end of the file
}
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 TestReadFileTool_DefaultOffsetAndRemainingLines(t *testing.T) {
func TestReadFileLinesTool_DefaultOffsetAndRemainingLines(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "default_lines.txt")
@ -854,11 +912,11 @@ func TestReadFileTool_DefaultOffsetAndRemainingLines(t *testing.T) {
if result.IsError {
t.Fatalf("Execute() error = %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "line 1\nline 2\nline 3") {
if !strings.Contains(result.ForLLM, "line 1\nline 2\nline 3\n") {
t.Fatalf("expected remaining lines by default, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "read: lines 1-3") {
t.Fatalf("expected line range 1-3, got: %s", result.ForLLM)
if !strings.Contains(result.ForLLM, "lines 0-2") {
t.Fatalf("expected line range 0-2, got: %s", result.ForLLM)
}
}
@ -891,6 +949,28 @@ func TestReadFileTool_LegacyLengthUsesByteModeForText(t *testing.T) {
}
}
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,
"offset": int64(100),
})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
if result.ForLLM != "[END OF FILE - no content at this offset]" {
t.Fatalf("unexpected EOF message: %q", result.ForLLM)
}
}
func TestReadFileLinesTool_BinaryFileRejected(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "binary.dat")
@ -909,40 +989,33 @@ func TestReadFileLinesTool_BinaryFileRejected(t *testing.T) {
if !strings.Contains(result.ForLLM, "file appears to be binary") {
t.Fatalf("expected binary file rejection message, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "use read_file") {
t.Fatalf("expected suggestion to use read_file, got: %s", result.ForLLM)
}
}
func TestReadFileTool_TruncatesLargeContentByEstimatedTokens(t *testing.T) {
func TestReadFileLinesTool_TruncatesSingleLongLineAtByteBudget(t *testing.T) {
tmpDir := t.TempDir()
testFile := filepath.Join(tmpDir, "long.txt")
testFile := filepath.Join(tmpDir, "long_line.txt")
var builder strings.Builder
for i := 1; i <= 40; i++ {
builder.WriteString(strings.Repeat("line-content-", 8))
builder.WriteString(strconv.Itoa(i))
builder.WriteString("\n")
}
err := os.WriteFile(testFile, []byte(builder.String()), 0o644)
content := strings.Repeat("x", 70*1024) + "\nsecond line\n"
err := os.WriteFile(testFile, []byte(content), 0o644)
if err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
tool := NewReadFileLinesTool(tmpDir, false, 160) // ~40 token output budget
tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)
result := tool.Execute(context.Background(), map[string]any{"path": testFile})
if result.IsError {
t.Fatalf("Execute() error = %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "... [Content truncated:") {
t.Fatalf("expected truncation notice, got: %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, "-> ~40 tokens limit") {
t.Fatalf("expected token limit notice derived from config, got: %s", result.ForLLM)
if !strings.Contains(result.ForLLM, "Use read_file for byte-wise inspection") {
t.Fatalf("expected byte-tool guidance for long line, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "line-content-line-content-") {
t.Fatalf("expected head of content to remain visible, got: %s", result.ForLLM)
}
if !strings.Contains(result.ForLLM, "line-content-line-content-40") {
t.Fatalf("expected tail of content to remain visible, got: %s", result.ForLLM)
if strings.Contains(result.ForLLM, "second line") {
t.Fatalf("did not expect second line after truncation, got: %s", result.ForLLM)
}
}