Skip to content

Commit 69f448d

Browse files
committed
v0.52.0 — Tool fallback elimination + edge case test coverage
Eliminates the shell-fallback feedback loop: all 15 perf tool descriptions now say 'Zero-fork — pure Go' instead of 'Replaces shell: cat, grep, bc...' which told the LLM exactly what shell command to use when tools failed. Fixes: - batch_patch: continue-on-error (was early-stop on first failure) - math_eval: added modulo (%) operator (most common fallback trigger) - diff: OOM guard (rejects files >10K lines before LCS allocation) - browser_tool: honest description (no-JS limitation noted) - read_file: points to base64/checksum instead of shell for binaries Tests: 57 new edge-case tests across all 15 perf tools + isBinary, http_batch. Every tool now has minimal edge coverage: math_eval(7), diff(3), count_lines(4), multi_grep(2), json_query(5), checksum(3), sort(2), base64(5), tr(5), word_count(3), tree(2), batch_patch(3), head_tail(2), glob(1), isBinary(5), http_batch(1)
1 parent 19b0207 commit 69f448d

6 files changed

Lines changed: 1072 additions & 44 deletions

File tree

cmd/odek/browser_tool.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ func (t *browserTool) Description() string {
8181
click — Follow a link or interact with an element by ref ID
8282
back — Return to the previous page in navigation history
8383
84+
Note: Uses regex-based HTML parsing with NO JavaScript execution. Best for server-rendered HTML pages. SPAs and JS-heavy sites may return limited content.
85+
8486
Use browser_navigate(url) first, then browser_snapshot() to see interactive
8587
elements with their ref IDs (e.g. @e1, @e2), then browser_click(ref) to
8688
follow links or interact with buttons.`

cmd/odek/file_tool.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ func (t *readFileTool) Description() string {
3131
return `Read a text file with line numbers and pagination.
3232
Returns file content prefixed with line numbers (LINE_NUM|CONTENT).
3333
Use offset and limit to read specific sections of large files.
34-
Cannot read binary files — use shell for that.`
34+
Cannot read binary files — use base64 or checksum for binary content.`
3535
}
3636

3737
func (t *readFileTool) Schema() any {
@@ -968,6 +968,7 @@ func (t *globTool) Name() string { return "glob" }
968968

969969
func (t *globTool) Description() string {
970970
return `Find files by glob pattern. Returns matching file paths sorted by modification time (newest first).
971+
Zero-fork — pure Go filepath walk with no subprocess.
971972
972973
Examples:
973974
glob(pattern="*.go") — all Go files in current directory
@@ -1134,7 +1135,7 @@ func (t *fileInfoTool) Description() string {
11341135
return `Get file or directory metadata without reading content.
11351136
Returns size, modification time, file mode, and type flags.
11361137
Uses Lstat — does NOT follow symlinks.
1137-
Use this instead of shell: ls, stat, or test commands.`
1138+
Zero-fork — pure Go file stat with no subprocess.`
11381139
}
11391140

11401141
type fileInfoArgs struct {

cmd/odek/perf_tools.go

Lines changed: 34 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -129,34 +129,28 @@ func (t *batchPatchTool) Call(argsJSON string) (result string, err error) {
129129
}
130130

131131
results := make([]batchPatchEntry, len(args.Patches))
132-
allOK := true
133-
stopIdx := 0
134132

135133
for idx, p := range args.Patches {
136-
stopIdx = idx
137134
entry := batchPatchEntry{Path: p.Path}
138135
if p.OldString == "" {
139136
entry.Error = "old_string is required"
140137
results[idx] = entry
141-
allOK = false
142-
break
138+
continue
143139
}
144140

145141
if err := t.dangerousConfig.CheckOperation(danger.ToolOperation{
146142
Name: "batch_patch", Resource: p.Path, Risk: danger.ClassifyPath(p.Path),
147143
}, nil); err != nil {
148144
entry.Error = err.Error()
149145
results[idx] = entry
150-
allOK = false
151-
break
146+
continue
152147
}
153148

154149
f, err := os.OpenFile(p.Path, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
155150
if err != nil {
156151
entry.Error = fmt.Sprintf("cannot open %q: %v", p.Path, err)
157152
results[idx] = entry
158-
allOK = false
159-
break
153+
continue
160154
}
161155

162156
var sb strings.Builder
@@ -165,16 +159,14 @@ func (t *batchPatchTool) Call(argsJSON string) (result string, err error) {
165159
if err != nil {
166160
entry.Error = fmt.Sprintf("cannot read %q: %v", p.Path, err)
167161
results[idx] = entry
168-
allOK = false
169-
break
162+
continue
170163
}
171164
original := sb.String()
172165

173166
if !strings.Contains(original, p.OldString) {
174167
entry.Error = fmt.Sprintf("old_string not found in %q", p.Path)
175168
results[idx] = entry
176-
allOK = false
177-
break
169+
continue
178170
}
179171

180172
var modified string
@@ -193,8 +185,7 @@ func (t *batchPatchTool) Call(argsJSON string) (result string, err error) {
193185
if err != nil {
194186
entry.Error = fmt.Sprintf("cannot create temp file: %v", err)
195187
results[idx] = entry
196-
allOK = false
197-
break
188+
continue
198189
}
199190
tmpPath := tmpFile.Name()
200191
tmpFile.Write([]byte(modified))
@@ -205,24 +196,14 @@ func (t *batchPatchTool) Call(argsJSON string) (result string, err error) {
205196
os.Remove(tmpPath)
206197
entry.Error = fmt.Sprintf("cannot write %q: %v", p.Path, err)
207198
results[idx] = entry
208-
allOK = false
209-
break
199+
continue
210200
}
211201

212202
entry.Success = true
213203
entry.Diff = diff
214204
results[idx] = entry
215205
}
216206

217-
if !allOK {
218-
for j := stopIdx + 1; j < len(args.Patches); j++ {
219-
results[j] = batchPatchEntry{
220-
Path: args.Patches[j].Path,
221-
Error: "skipped due to prior failure",
222-
}
223-
}
224-
}
225-
226207
return jsonResult(batchPatchResult{Results: results})
227208
}
228209

@@ -551,7 +532,7 @@ func (t *httpBatchTool) Call(argsJSON string) (result string, err error) {
551532
type mathEvalTool struct{}
552533

553534
func (t *mathEvalTool) Name() string { return "math_eval" }
554-
func (t *mathEvalTool) Description() string { return `Evaluate a math expression and return the result. Supports: +, -, *, /, parentheses, decimal numbers. Replaces shell: bc, expr, python -c forks. Example: "42 * 17 + 256 / 10"` }
535+
func (t *mathEvalTool) Description() string { return `Evaluate a math expression and return the result. Supports: +, -, *, /, %, parentheses, decimal numbers. Zero-fork — no subprocess spawned. Example: "42 * 17 + 256 / 10"` }
555536

556537
type mathEvalArgs struct {
557538
Expression string `json:"expression"`
@@ -634,6 +615,11 @@ func evalNode(node ast.Expr) (float64, error) {
634615
return 0, fmt.Errorf("division by zero")
635616
}
636617
return x / y, nil
618+
case token.REM:
619+
if y == 0 {
620+
return 0, fmt.Errorf("modulo by zero")
621+
}
622+
return float64(int64(x) % int64(y)), nil
637623
default:
638624
return 0, fmt.Errorf("unsupported operator: %s", n.Op)
639625
}
@@ -662,7 +648,7 @@ type diffTool struct {
662648
}
663649

664650
func (t *diffTool) Name() string { return "diff" }
665-
func (t *diffTool) Description() string { return `Compare two files and return structured hunks. Each hunk has a type (equal/added/removed) and line-by-line content. Use path_a+path_b for file-vs-file, or path+content for file-vs-string. Replaces shell: diff fork.` }
651+
func (t *diffTool) Description() string { return `Compare two files and return structured hunks. Each hunk has a type (equal/added/removed) and line-by-line content. Use path_a+path_b for file-vs-file, or path+content for file-vs-string. Zero-fork LCS-based diff — no subprocess spawned.` }
666652

667653
type diffArgs struct {
668654
PathA string `json:"path_a,omitempty"`
@@ -752,6 +738,17 @@ func (t *diffTool) Call(argsJSON string) (result string, err error) {
752738
return jsonError("provide either path_a+path_b or path+content")
753739
}
754740

741+
// OOM protection: the LCS table allocates (m+1)*(n+1) ints.
742+
// For 10K+10K lines that's ~800MB — reject early to avoid panic.
743+
const maxDiffLines = 10000
744+
if len(linesA) > maxDiffLines || len(linesB) > maxDiffLines {
745+
return jsonResult(diffResult{
746+
Error: fmt.Sprintf("files too large for in-process diff (%d vs %d lines, max %d).",
747+
len(linesA), len(linesB), maxDiffLines),
748+
PathA: pathA, PathB: pathB,
749+
})
750+
}
751+
755752
// Trim trailing empty from final newline
756753
if len(linesA) > 0 && linesA[len(linesA)-1] == "" {
757754
linesA = linesA[:len(linesA)-1]
@@ -838,7 +835,7 @@ type countLinesTool struct {
838835
}
839836

840837
func (t *countLinesTool) Name() string { return "count_lines" }
841-
func (t *countLinesTool) Description() string { return `Count lines, bytes, and characters in one or more files. Streaming scanner — zero-alloc on content. Replaces shell: wc -l, wc -c, wc -m forks.` }
838+
func (t *countLinesTool) Description() string { return `Count lines, bytes, and characters in one or more files. Streaming scanner — zero-alloc on content, zero subprocess forks. Per-file and aggregate totals.` }
842839

843840
type countFileArg struct {
844841
Path string `json:"path"`
@@ -1152,7 +1149,7 @@ type jsonQueryTool struct {
11521149
}
11531150

11541151
func (t *jsonQueryTool) Name() string { return "json_query" }
1155-
func (t *jsonQueryTool) Description() string { return `Parse a JSON file and extract a value using a dot-path query. Supports array indexing with [N]. Empty query returns the entire parsed JSON. Replaces shell: jq, python -c "import json" forks.` }
1152+
func (t *jsonQueryTool) Description() string { return `Parse a JSON file and extract a value using a dot-path query. Supports array indexing with [N]. Empty query returns the entire parsed JSON. Zero-fork — pure Go JSON traversal.` }
11561153

11571154
type jsonQueryArgs struct {
11581155
Path string `json:"path"`
@@ -1293,7 +1290,7 @@ type treeTool struct {
12931290
}
12941291

12951292
func (t *treeTool) Name() string { return "tree" }
1296-
func (t *treeTool) Description() string { return `List the directory tree with file counts, sizes, and nesting. Returns a structured tree: each entry shows path, is_dir, file_count, total_size, children, depth. Replaces shell: find, tree, ls -R forks.` }
1293+
func (t *treeTool) Description() string { return `List the directory tree with file counts, sizes, and nesting. Returns a structured tree: each entry shows path, is_dir, file_count, total_size, children, depth. Zero-fork — pure Go directory walk.` }
12971294

12981295
type treeArgs struct {
12991296
Path string `json:"path,omitempty"`
@@ -1424,7 +1421,7 @@ type checksumTool struct {
14241421
}
14251422

14261423
func (t *checksumTool) Name() string { return "checksum" }
1427-
func (t *checksumTool) Description() string { return `Compute cryptographic hashes of files using SHA-256 (default), SHA-1, or MD5. Uses Go crypto stdlib — zero subprocess fork. Replaces shell: sha256sum, sha1sum, md5sum forks.` }
1424+
func (t *checksumTool) Description() string { return `Compute cryptographic hashes of files using SHA-256 (default), SHA-1, or MD5. Uses Go crypto stdlib — zero subprocess fork, pure Go implementation.` }
14281425

14291426
type checksumFileArg struct {
14301427
Path string `json:"path"`
@@ -1555,7 +1552,7 @@ type sortTool struct {
15551552
}
15561553

15571554
func (t *sortTool) Name() string { return "sort" }
1558-
func (t *sortTool) Description() string { return `Sort lines in one or more files. Supports ascending (default), descending, unique (dedup), numeric, case-insensitive, and reverse. For multiple files, results are merged. Replaces shell: sort, sort -u, sort -n forks.` }
1555+
func (t *sortTool) Description() string { return `Sort lines in one or more files. Supports ascending (default), descending, unique (dedup), numeric, case-insensitive, and reverse. For multiple files, results are merged. Zero-fork — pure Go sort with no subprocess.` }
15591556

15601557
type sortArgs struct {
15611558
Path string `json:"path,omitempty"` // single file
@@ -1721,7 +1718,7 @@ type headTailTool struct {
17211718
}
17221719

17231720
func (t *headTailTool) Name() string { return "head_tail" }
1724-
func (t *headTailTool) Description() string { return `Read the first or last N lines of one or more files. Streaming — stops after N lines (no full-file read for head). Supports multiple files in parallel. Replaces shell: head -n, tail -n forks.` }
1721+
func (t *headTailTool) Description() string { return `Read the first or last N lines of one or more files. Streaming — stops after N lines (no full-file read for head). Supports multiple files in parallel. Zero-fork — pure Go scanner.` }
17251722

17261723
type headTailFileArg struct {
17271724
Path string `json:"path"`
@@ -1875,7 +1872,7 @@ type base64Tool struct {
18751872
}
18761873

18771874
func (t *base64Tool) Name() string { return "base64" }
1878-
func (t *base64Tool) Description() string { return `Encode or decode base64. Supports file input (path) or inline string (content). Encode: file or string → base64. Decode: base64 string → decoded string. Replaces shell: base64 fork. Use path for file, content for inline string, decode=true to decode.` }
1875+
func (t *base64Tool) Description() string { return `Encode or decode base64. Supports file input (path) or inline string (content). Encode: file or string → base64. Decode: base64 string → decoded string. Zero-fork — pure Go encoding. Use path for file, content for inline string, decode=true to decode.` }
18791876

18801877
type base64Args struct {
18811878
Path string `json:"path,omitempty"`
@@ -1963,7 +1960,7 @@ type trTool struct {
19631960
}
19641961

19651962
func (t *trTool) Name() string { return "tr" }
1966-
func (t *trTool) Description() string { return `Transform text: case conversion, character replacement, string substitution, character deletion. Operates on a file or inline content. Replaces shell: tr, sed (simple substitutions) forks.` }
1963+
func (t *trTool) Description() string { return `Transform text: case conversion, character replacement, string substitution, character deletion. Operates on a file or inline content. Zero-fork — pure Go strings transformations.` }
19671964

19681965
type trTransform struct {
19691966
From string `json:"from,omitempty"` // for char/string replacement
@@ -2096,7 +2093,7 @@ type wordCountTool struct {
20962093
}
20972094

20982095
func (t *wordCountTool) Name() string { return "word_count" }
2099-
func (t *wordCountTool) Description() string { return `Count words, lines, and characters in one or more files. Streaming scanner — no full-content load. Returns per-file and aggregate totals. Replaces shell: wc fork.` }
2096+
func (t *wordCountTool) Description() string { return `Count words, lines, and characters in one or more files. Streaming scanner — no full-content load. Returns per-file and aggregate totals. Zero-fork — pure Go scanner.` }
21002097

21012098
type wordCountFileArg struct {
21022099
Path string `json:"path"`

0 commit comments

Comments
 (0)