mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-09 05:19:33 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85b1689a62 | ||
|
|
6a258e1876 | ||
|
|
e3337d06d5 | ||
|
|
3aa190bd7f | ||
|
|
00ad4d4b8d | ||
|
|
a394fd60ee |
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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>`
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"] == "" {
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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{
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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). " +
|
||||||
|
|||||||
Reference in new issue
Block a user