Compare commits

...
15 changed files with 598 additions and 78 deletions

No files matched your search

+13 -7
View File
@@ -30,11 +30,13 @@ work.
read-before-edit discipline: inspect the target file and nearby callers, read-before-edit discipline: inspect the target file and nearby callers,
tests, or config before you modify behavior. Never edit a file you have not tests, or config before you modify behavior. Never edit a file you have not
read. read.
2. **Plan.** For multi-step work, call update_plan with an ordered checklist and 2. **Plan.** Use update_plan for implementation or investigation that has at
keep it current at meaningful milestones. Mark completed work and the next least three meaningful dependent steps, then keep it current at milestones.
in-progress step together when practical; do not spend separate model turns Mark completed work and the next in-progress step together when practical;
updating the plan after every file or command. Keep at most one item do not spend separate turns updating it after every file or command. Keep at
in_progress. Skip the plan for trivial one-step tasks. most one item in_progress. Skip plans for simple lookups, explanations, code
navigation, and other short tasks. Never create a plan after the work is
already complete merely to describe what you did.
3. **Implement.** Make focused changes that match the surrounding code's style, 3. **Implement.** Make focused changes that match the surrounding code's style,
naming, and conventions. Prefer the smallest change that fully solves the naming, and conventions. Prefer the smallest change that fully solves the
problem. Avoid broad refactors, unrelated rewrites, dependency churn, and problem. Avoid broad refactors, unrelated rewrites, dependency churn, and
@@ -49,9 +51,13 @@ work.
## Editing discipline ## Editing discipline
- Choose the narrowest tool that safely accomplishes the step. Prefer native - Choose the narrowest tool that safely accomplishes the step. Prefer native
file tools - read_file, list_directory, glob, grep, write_file, edit_file, file tools - read_file, read_minified_file, list_directory, glob, grep,
apply_patch - over shelling out to cat/sed/awk/python for file operations. write_file, edit_file, apply_patch - over shelling out to
cat/sed/awk/python for file operations.
They are safer, reviewable, and produce clean diffs. They are safer, reviewable, and produce clean diffs.
- Prefer read_minified_file when initially exploring source code; it preserves
code while removing comments and redundant whitespace. Use read_file when
exact text, comments, or line numbers are needed.
- Keep edits focused and reviewable. A single patch may update several related - Keep edits focused and reviewable. A single patch may update several related
files when they form one coherent change; do not hide unrelated edits in a files when they form one coherent change; do not hide unrelated edits in a
bulk shell or script rewrite. bulk shell or script rewrite.
+2 -1
View File
@@ -71,5 +71,6 @@ const geminiPromptAddendum = `<model_guidance>
equivalent shell commands; they are safer and produce cleaner diffs. equivalent shell commands; they are safer and produce cleaner diffs.
- Be concise and concrete. When you run a shell command with side effects, state - Be concise and concrete. When you run a shell command with side effects, state
in one short clause why it is needed. in one short clause why it is needed.
- Use update_plan for any multi-step task and keep it current. - Use update_plan only for work with at least three meaningful dependent steps;
skip it for simple lookups and never create one after the work is complete.
</model_guidance>` </model_guidance>`
+2 -2
View File
@@ -79,8 +79,8 @@ func TestRegisterToolSearchIfEligibleSkipsWhenThresholdZero(t *testing.T) {
func TestDeferredEligibleCountIncludesOptionalBuiltins(t *testing.T) { func TestDeferredEligibleCountIncludesOptionalBuiltins(t *testing.T) {
registry := newCoreRegistry(t.TempDir()) registry := newCoreRegistry(t.TempDir())
if got := deferredEligibleCount(registry, agent.PermissionModeAuto, nil, nil); got < 3 { if got := deferredEligibleCount(registry, agent.PermissionModeAuto, nil, nil); got < 2 {
t.Fatalf("deferredEligibleCount(core) = %d, want at least 3 optional built-ins", got) t.Fatalf("deferredEligibleCount(core) = %d, want at least 2 optional built-ins", got)
} }
} }
+134 -2
View File
@@ -13,8 +13,10 @@ package minify
import ( import (
"bytes" "bytes"
"go/ast"
"go/parser" "go/parser"
"go/printer" "go/printer"
"go/scanner"
"go/token" "go/token"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -34,8 +36,11 @@ func File(path string, content []byte) Result {
if out, ok := minifyGo(content); ok { if out, ok := minifyGo(content); ok {
return Result{Content: out, Language: "go", Applied: true} return Result{Content: out, Language: "go", Applied: true}
} }
// Unparsable Go (a snippet, generics edge, or syntax error): fall through if out, ok := minifyGoFragment(content); ok {
// to the safe generic path rather than risk a partial AST reprint. return Result{Content: out, Language: "go-fragment", Applied: true}
}
// An incomplete fragment that cannot be wrapped safely falls through to
// whitespace-only normalization; no source text is guessed or discarded.
} else if style, ok := commentStyles[ext]; ok { } else if style, ok := commentStyles[ext]; ok {
// Strip comments with a string-aware lexer, then collapse the whitespace the // Strip comments with a string-aware lexer, then collapse the whitespace the
// removed comments left behind. The stripper only handles languages whose // removed comments left behind. The stripper only handles languages whose
@@ -46,6 +51,133 @@ func File(path string, content []byte) Result {
return Result{Content: minifyGeneric(content), Language: "text", Applied: false} return Result{Content: minifyGeneric(content), Language: "text", Applied: false}
} }
// Fragment minifies a bounded source range without assuming lexical state from
// text outside the range. Go remains parser-validated; other languages use the
// conservative whitespace-only transform because a range may begin inside a
// multiline string or block comment.
func Fragment(path string, content []byte) Result {
if strings.EqualFold(filepath.Ext(path), ".go") {
if out, ok := minifyGo(content); ok {
return Result{Content: out, Language: "go", Applied: true}
}
if out, ok := minifyGoFragment(content); ok {
return Result{Content: out, Language: "go-fragment", Applied: true}
}
}
return Result{Content: minifyGeneric(content), Language: "text", Applied: false}
}
// ContextualFragment minifies a bounded fragment after checking its starting
// position against the complete source. A Go range that begins inside a string
// or comment must remain verbatim apart from whitespace normalization: parsing
// that fragment by itself would mistake literal text for Go syntax.
func ContextualFragment(path string, source, content []byte, startOffset int) Result {
if strings.EqualFold(filepath.Ext(path), ".go") && goFragmentStartsInsideToken(source, startOffset) {
return Result{Content: minifyGeneric(content), Language: "text", Applied: false}
}
return Fragment(path, content)
}
func goFragmentStartsInsideToken(source []byte, startOffset int) bool {
if startOffset <= 0 || startOffset >= len(source) {
return false
}
fset := token.NewFileSet()
file := fset.AddFile("", fset.Base(), len(source))
var lexer scanner.Scanner
lexer.Init(file, source, nil, scanner.ScanComments)
for {
position, kind, literal := lexer.Scan()
if kind == token.EOF {
return false
}
if kind != token.STRING && kind != token.COMMENT {
continue
}
start := file.Offset(position)
if startOffset > start && startOffset < start+len(literal) {
return true
}
}
}
// minifyGoFragment handles bounded reads that do not contain a package clause.
// It finds the longest complete declaration or statement prefix that the real
// Go parser accepts, prints that prefix without comments, and preserves any
// incomplete tail conservatively. Ranges are intentionally capped: repeatedly
// parsing a large broken file would otherwise turn a cheap read into O(n²) work.
func minifyGoFragment(content []byte) (string, bool) {
lines := strings.Split(strings.ReplaceAll(string(content), "\r\n", "\n"), "\n")
if len(lines) > 1 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
if len(lines) > 512 {
return "", false
}
for end := len(lines); end > 0; end-- {
prefix := strings.Join(lines[:end], "\n")
if compact, ok := parseGoDeclarations(prefix); ok {
return joinCompactPrefix(compact, lines[end:]), true
}
if compact, ok := parseGoStatements(prefix); ok {
return joinCompactPrefix(compact, lines[end:]), true
}
}
return "", false
}
func parseGoDeclarations(fragment string) (string, bool) {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "", "package compact\n"+fragment, parser.SkipObjectResolution)
if err != nil || len(file.Decls) == 0 {
return "", false
}
nodes := make([]ast.Node, len(file.Decls))
for i, declaration := range file.Decls {
nodes[i] = declaration
}
return printGoNodes(fset, nodes)
}
func parseGoStatements(fragment string) (string, bool) {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "", "package compact\nfunc _(){\n"+fragment+"\n}", parser.SkipObjectResolution)
if err != nil || len(file.Decls) != 1 {
return "", false
}
function, ok := file.Decls[0].(*ast.FuncDecl)
if !ok || function.Body == nil || len(function.Body.List) == 0 {
return "", false
}
nodes := make([]ast.Node, len(function.Body.List))
for i, statement := range function.Body.List {
nodes[i] = statement
}
return printGoNodes(fset, nodes)
}
func printGoNodes(fset *token.FileSet, nodes []ast.Node) (string, bool) {
var buf bytes.Buffer
cfg := printer.Config{Mode: printer.TabIndent, Tabwidth: 1}
for i, node := range nodes {
if i > 0 {
buf.WriteByte('\n')
}
if err := cfg.Fprint(&buf, fset, node); err != nil {
return "", false
}
}
return strings.TrimSpace(buf.String()), true
}
func joinCompactPrefix(compact string, remainder []string) string {
tail := minifyGeneric([]byte(strings.Join(remainder, "\n")))
if tail == "" {
return compact
}
return compact + "\n" + tail
}
// minifyGo parses Go WITHOUT comments (omitting parser.ParseComments leaves them // minifyGo parses Go WITHOUT comments (omitting parser.ParseComments leaves them
// unattached) and reprints the AST, yielding valid, comment-free, gofmt-shaped // unattached) and reprints the AST, yielding valid, comment-free, gofmt-shaped
// source with tab indentation (1 char per level). It returns ok=false on any // source with tab indentation (1 char per level). It returns ok=false on any
+48 -7
View File
@@ -39,17 +39,58 @@ func Greet(name string) string {
} }
} }
func TestMinifyGoFallsBackOnUnparsableGo(t *testing.T) { func TestMinifyGoStatementFragment(t *testing.T) {
// A snippet (no package clause) cannot parse as a file -> safe generic path.
r := File("snippet.go", []byte("x := 1 \n\n\n// keep me\ny := 2\n")) r := File("snippet.go", []byte("x := 1 \n\n\n// keep me\ny := 2\n"))
if r.Applied { if !r.Applied || r.Language != "go-fragment" {
t.Fatalf("expected fallback for unparsable Go, got %+v", r) t.Fatalf("expected parsed Go fragment, got %+v", r)
} }
if strings.Contains(r.Content, "\n\n\n") { if strings.Contains(r.Content, "\n\n\n") {
t.Errorf("generic should collapse blank runs:\n%q", r.Content) t.Errorf("fragment should collapse blank runs:\n%q", r.Content)
} }
if !strings.Contains(r.Content, "// keep me") { if strings.Contains(r.Content, "keep me") || !strings.Contains(r.Content, "y := 2") {
t.Errorf("generic must NOT strip comments (unsafe without a parser): %q", r.Content) t.Errorf("fragment was not compacted safely: %q", r.Content)
}
}
func TestMinifyGoFragmentAccepts512LinesWithFinalNewline(t *testing.T) {
src := strings.Repeat("x++\n", 512)
r := File("snippet.go", []byte(src))
if !r.Applied || r.Language != "go-fragment" {
t.Fatalf("expected 512-line fragment to remain eligible, got %+v", r)
}
if got := strings.Count(r.Content, "x++"); got != 512 {
t.Fatalf("compacted statement count = %d, want 512", got)
}
}
func TestMinifyGoDeclarationPrefixPreservesIncompleteTail(t *testing.T) {
src := `func Handle737(n int) (int, error) {
if n%2 == 1 {
return 0, errBad
}
return n + 737, nil
}
// Handle738 begins outside the requested range.
func Handle738(n int) (int, error) {
if n%2 == 1 {`
r := File("range.go", []byte(src))
if !r.Applied || r.Language != "go-fragment" {
t.Fatalf("expected declaration-prefix compaction, got %+v", r)
}
if strings.Contains(r.Content, "Handle737 returns") || !strings.Contains(r.Content, "return n + 737") {
t.Fatalf("complete declaration was not compacted correctly:\n%s", r.Content)
}
if !strings.Contains(r.Content, "func Handle738") || !strings.Contains(r.Content, "if n%2 == 1 {") {
t.Fatalf("incomplete tail was not preserved:\n%s", r.Content)
}
}
func TestMinifyGoLargeInvalidInputFallsBackConservatively(t *testing.T) {
src := strings.Repeat("x := 1 // keep\n", 513)
r := File("broken.go", []byte(src))
if r.Applied || !strings.Contains(r.Content, "// keep") {
t.Fatalf("large invalid input must retain conservative fallback: %+v", r)
} }
} }
+35
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"testing" "testing"
) )
@@ -295,6 +296,40 @@ func TestEditFileAllowsSeenRangeAndRejectsUnseenRange(t *testing.T) {
} }
} }
func TestCanonicalLimitedReadAuthorizesEdit(t *testing.T) {
dir := t.TempDir()
var source strings.Builder
for line := 1; line <= 1000; line++ {
source.WriteString("line ")
source.WriteString(strconv.Itoa(line))
source.WriteByte('\n')
}
path := filepath.Join(dir, "f.txt")
if err := os.WriteFile(path, []byte(source.String()), 0o644); err != nil {
t.Fatal(err)
}
registry := safetyRegistry(t, dir)
tracker := NewFileTracker()
opts := grantedOpts(tracker)
read := registry.RunWithOptions(context.Background(), "read_file", map[string]any{
"path": "f.txt", "offset": 500, "limit": 10,
}, opts)
if read.Status != StatusOK || read.Truncated {
t.Fatalf("bounded canonical read must be exact: status=%s truncated=%v meta=%#v", read.Status, read.Truncated, read.Meta)
}
if read.Meta["seen_lines"] != "500-509" {
t.Fatalf("bounded canonical read did not receive exact-read credit: %#v", read.Meta)
}
edit := registry.RunWithOptions(context.Background(), "edit_file", map[string]any{
"path": "f.txt", "old_string": "line 505", "new_string": "updated 505",
}, opts)
if edit.Status != StatusOK {
t.Fatalf("edit inside canonical read range was refused: %s", edit.Output)
}
}
func TestEditFileReplaceAllRecordsEveryShiftedReplacementSpan(t *testing.T) { func TestEditFileReplaceAllRecordsEveryShiftedReplacementSpan(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
path := filepath.Join(dir, "f.txt") path := filepath.Join(dir, "f.txt")
+104 -2
View File
@@ -38,6 +38,108 @@ func TestReadFileToolReadsLineRanges(t *testing.T) {
} }
} }
func TestReadFileToolReadsCanonicalLineRange(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\ngamma\ndelta")
result := NewScopedReadFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "notes.txt",
"offset": 2,
"limit": 2,
})
if result.Status != StatusOK {
t.Fatalf("expected ok status, got %s: %s", result.Status, result.Output)
}
if result.Truncated {
t.Fatalf("canonical limit defines the requested range and must not mark it truncated: %#v", result.Meta)
}
if !strings.Contains(result.Output, "2 | beta") || !strings.Contains(result.Output, "3 | gamma") {
t.Fatalf("canonical range returned the wrong lines: %q", result.Output)
}
if strings.Contains(result.Output, "alpha") || strings.Contains(result.Output, "delta") {
t.Fatalf("canonical range leaked outside requested slice: %q", result.Output)
}
}
func TestReadFileToolCanonicalOffsetPastEnd(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta")
result := NewScopedReadFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "notes.txt", "offset": 10,
})
if result.Status != StatusOK || !strings.Contains(result.Output, "offset 10 is past the end") {
t.Fatalf("expected canonical out-of-range message, got status=%s output=%q", result.Status, result.Output)
}
if strings.Contains(result.Output, "start_line") {
t.Fatalf("out-of-range message exposed legacy argument: %q", result.Output)
}
}
func TestReadFileToolMixedLegacyRangesPreferLines(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\ngamma\ndelta")
result := NewScopedReadFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "notes.txt",
"start_line": 2,
"end_line": 3,
"max_lines": 2,
"byte_offset": 0,
"byte_limit": readFileByteChunkMax,
})
if result.Status != StatusOK {
t.Fatalf("mixed legacy range should recover, got %s: %s", result.Status, result.Output)
}
if !strings.Contains(result.Output, "2 | beta") || !strings.Contains(result.Output, "3 | gamma") {
t.Fatalf("mixed legacy range did not prefer lines: %q", result.Output)
}
}
func TestReadFileToolCombinesLegacyStartWithCanonicalLimit(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "notes.txt"), "alpha\nbeta\ngamma\ndelta")
result := NewScopedReadFileTool(root, nil).Run(context.Background(), map[string]any{
"path": "notes.txt", "start_line": 2, "limit": 2,
})
if result.Status != StatusOK || result.Truncated {
t.Fatalf("mixed compatible range should be exact, got status=%s truncated=%v: %s", result.Status, result.Truncated, result.Output)
}
if !strings.Contains(result.Output, "2 | beta") || !strings.Contains(result.Output, "3 | gamma") {
t.Fatalf("mixed compatible range returned the wrong lines: %q", result.Output)
}
if strings.Contains(result.Output, "alpha") || strings.Contains(result.Output, "delta") {
t.Fatalf("mixed compatible range leaked outside requested slice: %q", result.Output)
}
}
func TestFileToolSchemasExposeOnlyCanonicalArguments(t *testing.T) {
readProperties := NewScopedReadFileTool(t.TempDir(), nil).Parameters().Properties
for _, want := range []string{"path", "offset", "limit", "byte_offset", "byte_limit"} {
if _, ok := readProperties[want]; !ok {
t.Fatalf("read_file schema missing %q", want)
}
}
for _, legacy := range []string{"start_line", "end_line", "max_lines"} {
if _, ok := readProperties[legacy]; ok {
t.Fatalf("read_file schema must not expose legacy argument %q", legacy)
}
}
grepProperties := NewScopedGrepTool(t.TempDir(), nil).Parameters().Properties
if _, ok := grepProperties["case_insensitive"]; !ok {
t.Fatal("grep schema missing case_insensitive")
}
if _, ok := grepProperties["-i"]; ok {
t.Fatal("grep schema must not expose the -i alias")
}
}
func TestReadFileToolMarksTruncation(t *testing.T) { func TestReadFileToolMarksTruncation(t *testing.T) {
root := t.TempDir() root := t.TempDir()
writeTestFile(t, filepath.Join(root, "notes.txt"), "a\nb\nc\nd\ne") writeTestFile(t, filepath.Join(root, "notes.txt"), "a\nb\nc\nd\ne")
@@ -52,7 +154,7 @@ func TestReadFileToolMarksTruncation(t *testing.T) {
} }
// The cut must be visible in the rendered output, not just the Truncated flag, // The cut must be visible in the rendered output, not just the Truncated flag,
// and must tell the model where to continue. // and must tell the model where to continue.
if !strings.Contains(result.Output, "[truncated:") || !strings.Contains(result.Output, "start_line=3") { if !strings.Contains(result.Output, "[truncated:") || !strings.Contains(result.Output, "offset=3") {
t.Fatalf("expected truncation marker pointing to the next line, got %q", result.Output) t.Fatalf("expected truncation marker pointing to the next line, got %q", result.Output)
} }
} }
@@ -68,7 +170,7 @@ func TestReadFileToolAppliesByteBudget(t *testing.T) {
if result.Status != StatusOK || !result.Truncated { if result.Status != StatusOK || !result.Truncated {
t.Fatalf("expected ok+truncated, got status=%s truncated=%v", result.Status, result.Truncated) t.Fatalf("expected ok+truncated, got status=%s truncated=%v", result.Status, result.Truncated)
} }
if !strings.Contains(result.Output, "output exceeded") || !strings.Contains(result.Output, "start_line") { if !strings.Contains(result.Output, "output exceeded") || !strings.Contains(result.Output, "offset/limit") {
t.Fatalf("expected byte-budget continuation hint, got %q", result.Output[len(result.Output)-200:]) t.Fatalf("expected byte-budget continuation hint, got %q", result.Output[len(result.Output)-200:])
} }
if result.Meta["raw_bytes"] == "" || result.Meta["emitted_bytes"] == "" { if result.Meta["raw_bytes"] == "" || result.Meta["emitted_bytes"] == "" {
+1 -1
View File
@@ -377,5 +377,5 @@ func fileConflictMessage(relativePath string) string {
} }
func fileUnseenMessage(relativePath string) string { func fileUnseenMessage(relativePath string) string {
return "Error writing " + relativePath + ": the intended change depends on content that has not been read exactly in this session. Read the affected lines with read_file, or use byte_offset/byte_limit for an oversized single line, then retry the edit." return "Error writing " + relativePath + ": the intended change depends on content that has not been read exactly in this session. Read the affected lines with read_file using offset/limit (or byte_offset/byte_limit for a very long line), then retry the edit."
} }
-1
View File
@@ -48,7 +48,6 @@ func NewScopedGrepTool(workspaceRoot string, scope PathScope) Tool {
"path": {Type: "string", Description: "Directory or file to search. Relative paths stay in the workspace; use an absolute path to search a granted extra root. Defaults to workspace root.", Default: "."}, "path": {Type: "string", Description: "Directory or file to search. Relative paths stay in the workspace; use an absolute path to search a granted extra root. Defaults to workspace root.", Default: "."},
"glob": {Type: "string", Description: `Optional glob filter, for example "**/*.go".`}, "glob": {Type: "string", Description: `Optional glob filter, for example "**/*.go".`},
"output_mode": {Type: "string", Description: "Output mode.", Enum: []string{"content", "files_with_matches", "count"}, Default: "content"}, "output_mode": {Type: "string", Description: "Output mode.", Enum: []string{"content", "files_with_matches", "count"}, Default: "content"},
"-i": {Type: "boolean", Description: "Case insensitive search.", Default: false},
"case_insensitive": {Type: "boolean", Description: "Case insensitive search.", Default: false}, "case_insensitive": {Type: "boolean", Description: "Case insensitive search.", Default: false},
"head_limit": {Type: "integer", Description: "Maximum content results to return.", Default: 50, Minimum: intPtr(1)}, "head_limit": {Type: "integer", Description: "Maximum content results to return.", Default: 50, Minimum: intPtr(1)},
}, },
+50 -25
View File
@@ -31,16 +31,15 @@ func NewScopedReadFileTool(workspaceRoot string, scope PathScope) Tool {
return readFileTool{ return readFileTool{
baseTool: baseTool{ baseTool: baseTool{
name: "read_file", name: "read_file",
description: "Read a file by 1-based inclusive line range, or by exact byte chunks for oversized single-line files.", description: "Read exact file text with line numbers. Use for comments, formatting, or edits; prefer read_minified_file for initial code understanding. Use offset and limit for a line range.",
parameters: Schema{ parameters: Schema{
Type: "object", Type: "object",
Properties: map[string]PropertySchema{ Properties: map[string]PropertySchema{
"path": {Type: "string", Description: "Path of the file to read."}, "path": {Type: "string", Description: "File path."},
"start_line": {Type: "integer", Description: "1-based inclusive line number to start reading from.", Minimum: intPtr(1)}, "offset": {Type: "integer", Description: "Optional 1-based source line to start from.", Minimum: intPtr(1)},
"end_line": {Type: "integer", Description: "1-based inclusive line number to stop reading at.", Minimum: intPtr(1)}, "limit": {Type: "integer", Description: "Optional number of source lines to return.", Minimum: intPtr(1)},
"max_lines": {Type: "integer", Description: "Maximum number of lines to return.", Minimum: intPtr(1)}, "byte_offset": {Type: "integer", Description: "Optional zero-based offset for exact byte reads.", Minimum: intPtr(0)},
"byte_offset": {Type: "integer", Description: "Zero-based byte offset for an exact chunk read. Use with byte_limit for oversized single-line files; cannot be combined with line ranges.", Minimum: intPtr(0)}, "byte_limit": {Type: "integer", Description: "Optional byte count for an exact byte read; use with byte_offset.", Minimum: intPtr(1), Maximum: intPtr(readFileByteChunkMax)},
"byte_limit": {Type: "integer", Description: "Maximum source bytes returned in exact byte mode. Defaults to 65536.", Default: readFileByteChunkMax, Minimum: intPtr(1), Maximum: intPtr(readFileByteChunkMax)},
}, },
Required: []string{"path"}, Required: []string{"path"},
AdditionalProperties: false, AdditionalProperties: false,
@@ -69,7 +68,27 @@ func (tool readFileTool) run(args map[string]any, options RunOptions, directBudg
if err != nil { if err != nil {
return errorResult("Error: Invalid arguments for read_file: " + err.Error()) return errorResult("Error: Invalid arguments for read_file: " + err.Error())
} }
startLine, err := intArg(args, "start_line", 1, 1, 0) _, hasOffset := args["offset"]
_, hasLimit := args["limit"]
_, hasStartLine := args["start_line"]
_, hasEndLine := args["end_line"]
_, hasMaxLines := args["max_lines"]
if hasOffset && hasStartLine {
return errorResult("Error: Invalid arguments for read_file: use either offset or start_line, not both")
}
if hasLimit && (hasEndLine || hasMaxLines) {
return errorResult("Error: Invalid arguments for read_file: use limit instead of end_line or max_lines, not both")
}
startKey := "offset"
if !hasOffset && hasStartLine {
startKey = "start_line"
}
startLine, err := intArg(args, startKey, 1, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for read_file: " + err.Error())
}
limit, err := intArg(args, "limit", 0, 1, 0)
if err != nil { if err != nil {
return errorResult("Error: Invalid arguments for read_file: " + err.Error()) return errorResult("Error: Invalid arguments for read_file: " + err.Error())
} }
@@ -81,19 +100,25 @@ func (tool readFileTool) run(args map[string]any, options RunOptions, directBudg
if err != nil { if err != nil {
return errorResult("Error: Invalid arguments for read_file: " + err.Error()) return errorResult("Error: Invalid arguments for read_file: " + err.Error())
} }
if hasLimit {
const maxInt = int(^uint(0) >> 1)
endLine = startLine + min(limit-1, maxInt-startLine)
}
hasLineRange := hasOffset || hasLimit || hasStartLine || hasEndLine || hasMaxLines
_, hasByteOffset := args["byte_offset"] _, hasByteOffset := args["byte_offset"]
_, hasByteLimit := args["byte_limit"] _, hasByteLimit := args["byte_limit"]
byteMode := hasByteOffset || hasByteLimit byteMode := (hasByteOffset || hasByteLimit) && !hasLineRange
byteOffset, err := intArg(args, "byte_offset", 0, 0, 0) byteOffset := 0
if err != nil { byteLimit := readFileByteChunkMax
return errorResult("Error: Invalid arguments for read_file: " + err.Error()) if byteMode {
} byteOffset, err = intArg(args, "byte_offset", 0, 0, 0)
byteLimit, err := intArg(args, "byte_limit", readFileByteChunkMax, 1, readFileByteChunkMax) if err != nil {
if err != nil { return errorResult("Error: Invalid arguments for read_file: " + err.Error())
return errorResult("Error: Invalid arguments for read_file: " + err.Error()) }
} byteLimit, err = intArg(args, "byte_limit", readFileByteChunkMax, 1, readFileByteChunkMax)
if byteMode && (args["start_line"] != nil || args["end_line"] != nil || args["max_lines"] != nil) { if err != nil {
return errorResult("Error: Invalid arguments for read_file: byte_offset/byte_limit cannot be combined with line ranges") return errorResult("Error: Invalid arguments for read_file: " + err.Error())
}
} }
absolutePath, relativePath, err := resolveScopedReadPath(tool.workspaceRoot, tool.scope, requestedPath) absolutePath, relativePath, err := resolveScopedReadPath(tool.workspaceRoot, tool.scope, requestedPath)
@@ -169,7 +194,7 @@ func renderedReadRange(total, start, end, maxLines int) (int, int) {
func renderReadFileRange(absolutePath string, relativePath string, total int, startLine int, endLine int, maxLines int, maxBytes int) Result { func renderReadFileRange(absolutePath string, relativePath string, total int, startLine int, endLine int, maxLines int, maxBytes int) Result {
if startLine > total { if startLine > total {
return okResult(fmt.Sprintf("File: %s\n(start_line %d is past the end of the file, which has %d lines)", relativePath, startLine, total)) return okResult(fmt.Sprintf("File: %s\n(offset %d is past the end of the file, which has %d lines)", relativePath, startLine, total))
} }
if endLine == 0 || endLine > total { if endLine == 0 || endLine > total {
endLine = total endLine = total
@@ -208,9 +233,9 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
// Tool.Run calls preserve the legacy prefix-only byte budget. // Tool.Run calls preserve the legacy prefix-only byte budget.
var budgetedOutput *outputBudgetBuilder var budgetedOutput *outputBudgetBuilder
if maxBytes > 0 { if maxBytes > 0 {
budgetedOutput = newOutputBudgetBuilder(maxBytes, "use start_line/end_line or max_lines for normal files; use byte_offset/byte_limit for an oversized single line") budgetedOutput = newOutputBudgetBuilder(maxBytes, "use offset/limit to request a smaller line range")
} else { } else {
budgetedOutput = newHeadTailOutputBudgetBuilder(readOutputBudgetBytes, "use start_line/end_line or max_lines for normal files; use byte_offset/byte_limit for an oversized single line") budgetedOutput = newHeadTailOutputBudgetBuilder(readOutputBudgetBytes, "use offset/limit to request a smaller line range")
} }
budgetedOutput.WriteString(header) budgetedOutput.WriteString(header)
budgetedOutput.WriteString("\n") budgetedOutput.WriteString("\n")
@@ -224,9 +249,9 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
} }
if truncated { if truncated {
// The Truncated flag alone is invisible to the model in the rendered // The Truncated flag alone is invisible to the model in the rendered
// output, so it cannot tell a max_lines cut from a complete read. Make the // output, so it cannot tell a limit cut from a complete read. Make the
// cut explicit and tell it how to continue. // cut explicit and tell it how to continue.
budgetedOutput.WriteString(fmt.Sprintf("\n\n[truncated: %d more line(s) in the requested range not shown; set start_line=%d to continue]", endLine-lastLine, lastLine+1)) budgetedOutput.WriteString(fmt.Sprintf("\n\n[truncated: %d more line(s) in the requested range not shown; set offset=%d to continue]", endLine-lastLine, lastLine+1))
} }
budgeted := budgetedOutput.Result() budgeted := budgetedOutput.Result()
@@ -241,7 +266,7 @@ func renderReadFileRange(absolutePath string, relativePath string, total int, st
if budgeted.Truncated { if budgeted.Truncated {
meta["truncation_reason"] = "byte_budget" meta["truncation_reason"] = "byte_budget"
} else { } else {
meta["truncation_reason"] = "max_lines" meta["truncation_reason"] = "limit"
} }
} }
return Result{ return Result{
+71 -8
View File
@@ -28,12 +28,13 @@ func NewScopedReadMinifiedFileTool(workspaceRoot string, scope PathScope) Tool {
return readMinifiedFileTool{ return readMinifiedFileTool{
baseTool: baseTool{ baseTool: baseTool{
name: "read_minified_file", name: "read_minified_file",
description: "Read a source file in a dense, token-cheap form: comments and redundant whitespace removed, no line numbers. Use it to scan or understand code for far fewer tokens than read_file. For exact text, comments, line numbers, or before editing, use read_file instead.", description: "Read source code in a token-efficient, language-aware form. Prefer this for initial understanding; use read_file for exact text or line numbers.",
deferred: true,
parameters: Schema{ parameters: Schema{
Type: "object", Type: "object",
Properties: map[string]PropertySchema{ Properties: map[string]PropertySchema{
"path": {Type: "string", Description: "Path of the file to read in minified form."}, "path": {Type: "string", Description: "Source file path."},
"offset": {Type: "integer", Description: "Optional 1-based source line to start from.", Minimum: intPtr(1)},
"limit": {Type: "integer", Description: "Optional maximum number of source lines to minify.", Minimum: intPtr(1)},
}, },
Required: []string{"path"}, Required: []string{"path"},
AdditionalProperties: false, AdditionalProperties: false,
@@ -59,6 +60,14 @@ func (tool readMinifiedFileTool) run(args map[string]any, options RunOptions, di
if err != nil { if err != nil {
return errorResult("Error: Invalid arguments for read_minified_file: " + err.Error()) return errorResult("Error: Invalid arguments for read_minified_file: " + err.Error())
} }
offset, err := intArg(args, "offset", 1, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for read_minified_file: " + err.Error())
}
limit, err := intArg(args, "limit", 0, 1, 0)
if err != nil {
return errorResult("Error: Invalid arguments for read_minified_file: " + err.Error())
}
absolutePath, relativePath, err := resolveScopedReadPath(tool.workspaceRoot, tool.scope, requestedPath) absolutePath, relativePath, err := resolveScopedReadPath(tool.workspaceRoot, tool.scope, requestedPath)
if err != nil { if err != nil {
@@ -75,11 +84,19 @@ func (tool readMinifiedFileTool) run(args map[string]any, options RunOptions, di
info, _ := os.Stat(absolutePath) info, _ := os.Stat(absolutePath)
options.FileTracker.Record(absolutePath, content, info) options.FileTracker.Record(absolutePath, content, info)
result := minify.File(relativePath, content) selected := selectSourceLines(content, offset, limit)
rawLines := lineCount(string(content)) if selected.pastEnd {
return okResult(fmt.Sprintf("File: %s\n(offset %d is past the end of the file, which has %d lines)", relativePath, offset, selected.totalLines))
}
ranged := offset > 1 || limit > 0
result := minify.File(relativePath, selected.content)
if ranged {
result = minify.ContextualFragment(relativePath, content, selected.content, selected.startByte)
}
rawLines := lineCount(string(selected.content))
minLines := lineCount(result.Content) minLines := lineCount(result.Content)
pct := 0 pct := 0
if rawBytes := len(content); rawBytes > 0 { if rawBytes := len(selected.content); rawBytes > 0 {
if saved := rawBytes - len(result.Content); saved > 0 { if saved := rawBytes - len(result.Content); saved > 0 {
pct = saved * 100 / rawBytes pct = saved * 100 / rawBytes
} }
@@ -89,12 +106,15 @@ func (tool readMinifiedFileTool) run(args map[string]any, options RunOptions, di
if result.Applied { if result.Applied {
header = fmt.Sprintf("File: %s — minified %s view (comments stripped, no line numbers; %d→%d lines, ~%d%% fewer bytes). For exact text/comments or before editing, use read_file.", header = fmt.Sprintf("File: %s — minified %s view (comments stripped, no line numbers; %d→%d lines, ~%d%% fewer bytes). For exact text/comments or before editing, use read_file.",
relativePath, result.Language, rawLines, minLines, pct) relativePath, result.Language, rawLines, minLines, pct)
} else if ranged {
header = fmt.Sprintf("File: %s — safe ranged view (whitespace normalized, no line numbers; %d→%d lines; context-sensitive stripping disabled because the range may begin inside a multiline construct). For exact text, use read_file.",
relativePath, rawLines, minLines)
} else { } else {
header = fmt.Sprintf("File: %s — whitespace-normalized view (no line numbers; %d→%d lines; full minification not available for this file type). For exact text, use read_file.", header = fmt.Sprintf("File: %s — whitespace-normalized view (no line numbers; %d→%d lines; full minification not available for this file type). For exact text, use read_file.",
relativePath, rawLines, minLines) relativePath, rawLines, minLines)
} }
rawBytes := len(content) rawBytes := len(selected.content)
compactBytes := len(result.Content) compactBytes := len(result.Content)
savedTokens := 0 savedTokens := 0
if savedBytes := rawBytes - compactBytes; savedBytes > 0 { if savedBytes := rawBytes - compactBytes; savedBytes > 0 {
@@ -113,11 +133,54 @@ func (tool readMinifiedFileTool) run(args map[string]any, options RunOptions, di
meta["estimated_tokens_saved"] = strconv.Itoa(savedTokens) meta["estimated_tokens_saved"] = strconv.Itoa(savedTokens)
toolResult := Result{Status: StatusOK, Output: output, Meta: meta} toolResult := Result{Status: StatusOK, Output: output, Meta: meta}
if directBudget { if directBudget {
return applyLegacyByteBudgetToResult(toolResult, readOutputBudgetBytes, "use read_file with start_line/end_line or max_lines for normal files, or byte_offset/byte_limit for an oversized single line") return applyLegacyByteBudgetToResult(toolResult, readOutputBudgetBytes, "use offset/limit to request a smaller source range, or read_file when exact text is required")
} }
return toolResult return toolResult
} }
type sourceSelection struct {
content []byte
startByte int
totalLines int
pastEnd bool
}
func selectSourceLines(content []byte, offset, limit int) sourceSelection {
if offset <= 1 && limit == 0 {
return sourceSelection{content: content, totalLines: sourceLineCount(content)}
}
lines := strings.Split(string(content), "\n")
totalLines := sourceLineCount(content)
start := offset - 1
if start >= totalLines {
return sourceSelection{startByte: len(content), totalLines: totalLines, pastEnd: true}
}
startByte := 0
for index := 0; index < start; index++ {
startByte += len(lines[index]) + 1
}
end := totalLines
if limit > 0 && limit < end-start {
end = start + limit
}
return sourceSelection{
content: []byte(strings.Join(lines[start:end], "\n")),
startByte: startByte,
totalLines: totalLines,
}
}
func sourceLineCount(content []byte) int {
if len(content) == 0 {
return 1
}
lines := strings.Count(string(content), "\n")
if content[len(content)-1] != '\n' {
lines++
}
return lines
}
// lineCount reports the number of newline-separated lines in s (an empty string // lineCount reports the number of newline-separated lines in s (an empty string
// counts as 0 lines, matching how a reader perceives an empty file). // counts as 0 lines, matching how a reader perceives an empty file).
func lineCount(s string) int { func lineCount(s string) int {
+117
View File
@@ -45,6 +45,123 @@ func TestReadMinifiedFileRejectsTraversal(t *testing.T) {
} }
} }
func TestReadMinifiedFileSelectsSourceLineRangeBeforeMinifying(t *testing.T) {
dir := t.TempDir()
src := "package demo\n\nfunc One() int { return 1 }\n\nfunc Two() int { return 2 }\n"
if err := os.WriteFile(filepath.Join(dir, "f.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
res := NewScopedReadMinifiedFileTool(dir, nil).Run(context.Background(), map[string]any{
"path": "f.go", "offset": 5, "limit": 1,
})
if res.Status != StatusOK || !strings.Contains(res.Output, "func Two") || strings.Contains(res.Output, "func One") {
t.Fatalf("unexpected ranged compact read: status=%s\n%s", res.Status, res.Output)
}
}
func TestReadMinifiedFileRangesPreserveUnknownLexicalContext(t *testing.T) {
tests := []struct {
name string
path string
src string
want []string
}{
{
name: "multiline string",
path: "snippet.py",
src: "value = \"\"\"\n# literal text\nstill literal\n\"\"\"\nprint(value)\n",
want: []string{"# literal text", "still literal"},
},
{
name: "template literal",
path: "snippet.js",
src: "const value = `\n// literal text\nstill literal\n`;\n",
want: []string{"// literal text", "still literal"},
},
{
name: "block comment",
path: "snippet.c",
src: "/* open\ncomment body\n*/\nint live;\n",
want: []string{"comment body", "*/"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, tc.path), []byte(tc.src), 0o644); err != nil {
t.Fatal(err)
}
res := NewScopedReadMinifiedFileTool(dir, nil).Run(context.Background(), map[string]any{
"path": tc.path, "offset": 2, "limit": 2,
})
if res.Status != StatusOK || res.Meta["compacted"] != "false" {
t.Fatalf("ranged read must use conservative normalization: status=%s meta=%#v\n%s", res.Status, res.Meta, res.Output)
}
for _, want := range tc.want {
if !strings.Contains(res.Output, want) {
t.Fatalf("ranged read lost lexical content %q:\n%s", want, res.Output)
}
}
})
}
}
func TestReadMinifiedFileRangeInsideGoRawStringIsConservative(t *testing.T) {
dir := t.TempDir()
src := "package demo\nvar value = `first\nSECRET-MARKER-ONE\n// literal line\nlast`\n"
if err := os.WriteFile(filepath.Join(dir, "f.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
res := NewScopedReadMinifiedFileTool(dir, nil).Run(context.Background(), map[string]any{
"path": "f.go", "offset": 3, "limit": 2,
})
if res.Status != StatusOK || res.Meta["compacted"] != "false" {
t.Fatalf("raw-string range must use conservative normalization: status=%s meta=%#v\n%s", res.Status, res.Meta, res.Output)
}
for _, want := range []string{"SECRET-MARKER-ONE", "// literal line"} {
if !strings.Contains(res.Output, want) {
t.Fatalf("raw-string range lost or rewrote %q:\n%s", want, res.Output)
}
}
if strings.Contains(res.Output, "SECRET - MARKER - ONE") {
t.Fatalf("raw-string contents were parsed as Go code:\n%s", res.Output)
}
}
func TestReadMinifiedFileCanonicalOffsetPastEnd(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "f.go"), []byte("package demo\nvar value = 1\n"), 0o644); err != nil {
t.Fatal(err)
}
res := NewScopedReadMinifiedFileTool(dir, nil).Run(context.Background(), map[string]any{
"path": "f.go", "offset": 10,
})
if res.Status != StatusOK || !strings.Contains(res.Output, "offset 10 is past the end") {
t.Fatalf("expected canonical out-of-range response, got status=%s output=%q", res.Status, res.Output)
}
}
func TestReadMinifiedFileMaximumLimitDoesNotOverflow(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("alpha\nbeta\ngamma\n"), 0o644); err != nil {
t.Fatal(err)
}
const maxInt = int(^uint(0) >> 1)
res := NewScopedReadMinifiedFileTool(dir, nil).Run(context.Background(), map[string]any{
"path": "notes.txt", "offset": 2, "limit": maxInt,
})
if res.Status != StatusOK {
t.Fatalf("maximum limit should return the remaining range without panicking: status=%s output=%q", res.Status, res.Output)
}
if !strings.Contains(res.Output, "beta") || !strings.Contains(res.Output, "gamma") || strings.Contains(res.Output, "alpha") {
t.Fatalf("maximum limit returned the wrong range: %q", res.Output)
}
}
func TestReadMinifiedFileAppliesByteBudget(t *testing.T) { func TestReadMinifiedFileAppliesByteBudget(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "large.txt"), []byte(strings.Repeat("0123456789abcdef\n", 9000)), 0o644); err != nil { if err := os.WriteFile(filepath.Join(dir, "large.txt"), []byte(strings.Repeat("0123456789abcdef\n", 9000)), 0o644); err != nil {
+1 -1
View File
@@ -297,8 +297,8 @@ func scrubResultSecrets(res Result) Result {
func CoreReadOnlyToolsScoped(workspaceRoot string, scope PathScope) []Tool { func CoreReadOnlyToolsScoped(workspaceRoot string, scope PathScope) []Tool {
return []Tool{ return []Tool{
NewScopedReadFileTool(workspaceRoot, scope),
NewScopedReadMinifiedFileTool(workspaceRoot, scope), NewScopedReadMinifiedFileTool(workspaceRoot, scope),
NewScopedReadFileTool(workspaceRoot, scope),
// view_image is read-only and shares read_file's path scoping, so it // view_image is read-only and shares read_file's path scoping, so it
// belongs with the other readers rather than behind a gate. // belongs with the other readers rather than behind a gate.
NewScopedViewImageTool(workspaceRoot, scope), NewScopedViewImageTool(workspaceRoot, scope),
+19 -20
View File
@@ -523,25 +523,24 @@ func TestIsDeferralEligibleDecouplesFromDeferred(t *testing.T) {
func TestOptionalBuiltinsUseDeferredDiscovery(t *testing.T) { func TestOptionalBuiltinsUseDeferredDiscovery(t *testing.T) {
wantDeferred := map[string]bool{ wantDeferred := map[string]bool{
"bash": true, "bash": true,
"browser_action": true, "browser_action": true,
"browser_click": true, "browser_click": true,
"browser_connect": true, "browser_connect": true,
"browser_install": true, "browser_install": true,
"browser_launch": true, "browser_launch": true,
"browser_open": true, "browser_open": true,
"browser_press": true, "browser_press": true,
"browser_snapshot": true, "browser_snapshot": true,
"browser_type": true, "browser_type": true,
"capture_artifact": true, "capture_artifact": true,
"desktop_action": true, "desktop_action": true,
"desktop_snapshot": true, "desktop_snapshot": true,
"desktop_windows": true, "desktop_windows": true,
"lsp_navigate": true, "lsp_navigate": true,
"read_minified_file": true, "terminal_session": true,
"terminal_session": true, "web_fetch": true,
"web_fetch": true, "web_search": true,
"web_search": true,
} }
for _, tool := range BuiltinCatalog(t.TempDir()) { for _, tool := range BuiltinCatalog(t.TempDir()) {
if _, listed := wantDeferred[tool.Name()]; listed { if _, listed := wantDeferred[tool.Name()]; listed {
@@ -549,7 +548,7 @@ func TestOptionalBuiltinsUseDeferredDiscovery(t *testing.T) {
t.Errorf("%s should be deferred", tool.Name()) t.Errorf("%s should be deferred", tool.Name())
} }
delete(wantDeferred, tool.Name()) delete(wantDeferred, tool.Name())
} else if tool.Name() == "read_file" || tool.Name() == "grep" || tool.Name() == ExecCommandToolName || tool.Name() == "edit_file" { } else if tool.Name() == "read_file" || tool.Name() == "read_minified_file" || tool.Name() == "grep" || tool.Name() == ExecCommandToolName || tool.Name() == "edit_file" {
if IsDeferred(tool) { if IsDeferred(tool) {
t.Errorf("essential tool %s must stay eager", tool.Name()) t.Errorf("essential tool %s must stay eager", tool.Name())
} }
+1 -1
View File
@@ -26,7 +26,7 @@ func NewUpdatePlanTool() *updatePlanTool {
return &updatePlanTool{ return &updatePlanTool{
baseTool: baseTool{ baseTool: baseTool{
name: "update_plan", name: "update_plan",
description: "Create or update the in-memory plan for the current task. " + description: "Create or update the in-memory plan for implementation or investigation with at least three meaningful dependent steps. Do not use it for simple lookups, explanations, or code navigation. " +
"Pass the full ordered list of steps each call; it replaces the previous plan. " + "Pass the full ordered list of steps each call; it replaces the previous plan. " +
"Each item needs a `content` string; `status` defaults to \"pending\" and `id` is " + "Each item needs a `content` string; `status` defaults to \"pending\" and `id` is " +
"auto-numbered, so you only need to supply `content` (and `status` as the task progresses). " + "auto-numbered, so you only need to supply `content` (and `status` as the task progresses). " +