Details and log output for this agent run.
{"path":".","recursive":false}
drwxr-xr-x 6 user user 4096 Aug 26 11:46 . drwxr-xr-x 1 root root 4096 Aug 26 11:46 .. drwxr-xr-x 7 user user 4096 Aug 26 11:46 .git -rw-r--r-- 1 user user 22 Aug 26 11:46 .gitignore -rw-r--r-- 1 user user 6387 Aug 26 11:46 README.md drwxr-xr-x 3 user user 4096 Aug 26 11:46 cmd drwxr-xr-x 2 user user 4096 Aug 26 11:46 docker -rw-r--r-- 1 user user 2628 Aug 26 11:46 go.mod -rw-r--r-- 1 user user 16578 Aug 26 11:46 go.sum drwxr-xr-x 11 user user 4096 Aug 26 11:46 internal -rw-r--r-- 1 user user 3092 Aug 26 11:46 zoo.hcl.example
{"query":"head -n"}
/project/internal/tools/grep_search.go:76: cmd := strings.Join(args, " ") + fmt.Sprintf(" | head -n %d", maxResults)
/project/internal/tools/tools_test.go:176: if !strings.HasSuffix(fc.lastCmd, shellQuote("/project/internal/store/store.go")+" | head -n 100") {
/project/internal/tools/tools_test.go:199: if !strings.HasSuffix(fc.lastCmd, shellQuote("/project")+" | head -n 100") {
{"end_line":120,"path":"internal/tools/grep_search.go","start_line":1}
{"path":"internal/tools/grep_search.go","content":"package tools\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/abrander/zoo/internal/llm\"\n)\n\ntype grepSearchParams struct {\n\tPattern string `json:\"query\"`\n\tGlob string `json:\"glob\"`\n\tCaseSensitive bool `json:\"case_sensitive\"`\n\tContextBefore int `json:\"context_before\"`\n\tContextAfter int `json:\"context_after\"`\n\tMaxResults int `json:\"max_results\"`\n}\n\nfunc init() {\n\ttool := llm.NewTool(\n\t\t\"grep_search\",\n\t\t\"Search for an extended regular expression (ERE, e.g. 'foo|bar', 'func\\\\(') in project files (in the project container). Supports context lines, a glob filter, and case sensitivity control.\")\n\n\ttool.AddStringProperty(\"query\", \"The regex pattern to search for\", true)\n\ttool.AddStringProperty(\"glob\", \"Optional glob pattern to filter files, e.g. '*.go'. A literal file path without wildcards, e.g. 'internal/store/store.go', is searched directly\", false)\n\ttool.AddBooleanProperty(\"case_sensitive\", \"Whether the search should be case sensitive (default: false)\", false)\n\ttool.AddIntegerProperty(\"context_before\", \"Number of lines of context to show before each match (default: 0)\", false)\n\ttool.AddIntegerProperty(\"context_after\", \"Number of lines of context to show after each match (default: 0)\", false)\n\ttool.AddIntegerProperty(\"max_results\", \"Maximum number of matching lines to return (default: 100, 0 means no limit)\", false)\n\n\tRegister(tool, grepSearch)\n}\n\nfunc grepSearch(ctx Context, params grepSearchParams) (string, error) {\n\tmaxResults := params.MaxResults\n\tif maxResults == 0 {\n\t\tmaxResults = 100\n\t}\n\n\t// -E makes grep interpret the pattern as an extended regular\n\t// expression (ERE), which is the \"real\" regex flavor the tool\n\t// promises: |, +, ?, (), {} all work as metacharacters. Without\n\t// it, grep falls back to BRE, where those are treated literally.\n\t// -H keeps the filename in the output even when the search target\n\t// is a single file, so results look the same as directory searches.\n\targs := []string{\"grep\", \"-Rn\", \"-I\", \"-H\", \"-E\"}\n\tif !params.CaseSensitive {\n\t\targs = append(args, \"-i\")\n\t}\n\n\tif params.ContextBefore \u003e 0 {\n\t\targs = append(args, \"-B\", strconv.Itoa(params.ContextBefore))\n\t}\n\n\tif params.ContextAfter \u003e 0 {\n\t\targs = append(args, \"-A\", strconv.Itoa(params.ContextAfter))\n\t}\n\n\t// By default the search target is the whole project. grep's\n\t// --include matches basenames against a glob, so a literal file\n\t// path (no wildcards) passed as the glob would never match;\n\t// treat such values as a path and search that file directly.\n\ttarget := \"/project\"\n\tif params.Glob != \"\" {\n\t\tif isGlobPattern(params.Glob) {\n\t\t\targs = append(args, \"--include=\"+shellQuote(params.Glob))\n\t\t} else {\n\t\t\ttarget = resolvePath(params.Glob)\n\t\t}\n\t}\n\n\targs = append(args, \"-e\", shellQuote(params.Pattern), shellQuote(target))\n\n\tcmd := strings.Join(args, \" \") + fmt.Sprintf(\" | head -n %d\", maxResults)\n\n\toutput, stderr, err := ctx.Exec(context.Background(), cmd)\n\tif err != nil {\n\t\t// grep exits non-zero (with empty output) when there are no\n\t\t// matches; that's a normal result, not a tool failure.\n\t\tif strings.TrimSpace(output) == \"\" \u0026\u0026 strings.TrimSpace(stderr) == \"\" {\n\t\t\treturn \"No matches found.\", nil\n\t\t}\n\n\t\treturn \"\", fmt.Errorf(\"grep_search: %w\", execErr(err, output, stderr))\n\t}\n\n\tif strings.TrimSpace(output) == \"\" {\n\t\t// The pipeline's exit status is head's, so a grep failure (e.g.\n\t\t// a bad regex) doesn't surface as an error; its diagnostic is on\n\t\t// stderr instead of in the match list.\n\t\tif msg := strings.TrimSpace(stderr); msg != \"\" {\n\t\t\treturn \"\", fmt.Errorf(\"grep_search: %s\", msg)\n\t\t}\n\n\t\treturn \"No matches found.\", nil\n\t}\n\n\treturn output, nil\n}\n\n// isGlobPattern reports whether s contains the glob metacharacters\n// fnmatch (and thus grep's --include) understands. A value without\n// them is a literal path, which --include would never match since it\n// globs basenames, not full paths.\nfunc isGlobPattern(s string) bool {\n\treturn strings.ContainsAny(s, \"*?[\")\n}","start_line":1,"end_line":109,"total_lines":109,"truncated":false}
{"end_line":230,"path":"internal/tools/tools_test.go","start_line":120}
{"path":"internal/tools/tools_test.go","content":"\t}\n\tif out != \"Review submitted\" {\n\t\tt.Fatalf(\"unexpected result: %q\", out)\n\t}\n\tif len(fg.reviews) != 1 || fg.reviews[0] != \"changes_requested\" {\n\t\tt.Fatalf(\"unexpected reviews: %v\", fg.reviews)\n\t}\n}\n\n// An approval needs no body, but any other verdict does.\nfunc TestSubmitReviewRequiresBodyUnlessApproved(t *testing.T) {\n\tfg := \u0026fakeForgejoActions{}\n\tfc := \u0026fakeContext{fg: fg}\n\n\tif _, err := submitReview(fc, submitReviewParams{State: \"approved\", Body: \"\"}); err != nil {\n\t\tt.Fatalf(\"approval with empty body should be allowed, got %v\", err)\n\t}\n\n\tif _, err := submitReview(fc, submitReviewParams{State: \"changes_requested\", Body: \" \"}); err == nil {\n\t\tt.Fatal(\"expected an error for a changes_requested review with an empty body\")\n\t}\n}\n\nfunc TestGrepSearchUsesExtendedRegex(t *testing.T) {\n\tfc := \u0026fakeContext{stdout: \"/project/foo.go:1:foo|bar\\n\"}\n\n\tout, err := grepSearch(fc, grepSearchParams{Pattern: `foo|bar`})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif !strings.Contains(fc.lastCmd, \"grep -Rn -I -H -E\") {\n\t\tt.Fatalf(\"expected grep to be invoked with -E (extended regex), got %q\", fc.lastCmd)\n\t}\n\tif !strings.Contains(out, \"foo|bar\") {\n\t\tt.Fatalf(\"unexpected result: %s\", out)\n\t}\n}\n\n// A literal file path in the glob parameter (no wildcards) must be\n// searched directly, not passed to --include, which globs basenames\n// and would never match a full path.\nfunc TestGrepSearchLiteralPathSearchesFileDirectly(t *testing.T) {\n\tfc := \u0026fakeContext{stdout: \"/project/internal/store/store.go:1:func (s *Store) Get() {}\\n\"}\n\n\tout, err := grepSearch(fc, grepSearchParams{\n\t\tPattern: `^func \\(s \\*Store\\)`,\n\t\tGlob: \"internal/store/store.go\",\n\t\tContextAfter: 4,\n\t})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif strings.Contains(fc.lastCmd, \"--include\") {\n\t\tt.Fatalf(\"literal path must not be passed to --include, got %q\", fc.lastCmd)\n\t}\n\tif !strings.HasSuffix(fc.lastCmd, shellQuote(\"/project/internal/store/store.go\")+\" | head -n 100\") {\n\t\tt.Fatalf(\"expected grep to target the literal path, got %q\", fc.lastCmd)\n\t}\n\tif !strings.Contains(fc.lastCmd, \"-A 4\") {\n\t\tt.Fatalf(\"expected context_after to be passed through, got %q\", fc.lastCmd)\n\t}\n\tif !strings.Contains(out, \"func (s *Store) Get()\") {\n\t\tt.Fatalf(\"unexpected result: %s\", out)\n\t}\n}\n\n// A glob with wildcards must still be passed to --include and the\n// search must target the whole project.\nfunc TestGrepSearchGlobStillUsesInclude(t *testing.T) {\n\tfc := \u0026fakeContext{stdout: \"/project/foo.go:1:foo\\n\"}\n\n\tif _, err := grepSearch(fc, grepSearchParams{Pattern: \"foo\", Glob: \"*.go\"}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif !strings.Contains(fc.lastCmd, \"--include='*.go'\") {\n\t\tt.Fatalf(\"expected glob to be passed to --include, got %q\", fc.lastCmd)\n\t}\n\tif !strings.HasSuffix(fc.lastCmd, shellQuote(\"/project\")+\" | head -n 100\") {\n\t\tt.Fatalf(\"expected glob search to target /project, got %q\", fc.lastCmd)\n\t}\n}\n\nfunc TestIsGlobPattern(t *testing.T) {\n\tcases := map[string]bool{\n\t\t\"*.go\": true,\n\t\t\"src/?*.go\": true,\n\t\t\"src/[abc].go\": true,\n\t\t\"store.go\": false,\n\t\t\"internal/store/store.go\": false,\n\t}\n\tfor in, want := range cases {\n\t\tif got := isGlobPattern(in); got != want {\n\t\t\tt.Errorf(\"isGlobPattern(%q) = %v, want %v\", in, got, want)\n\t\t}\n\t}\n}\n\nfunc TestGrepSearchNoMatches(t *testing.T) {\n\tfc := \u0026fakeContext{err: fmt.Errorf(\"exit status 1\")}\n\n\tout, err := grepSearch(fc, grepSearchParams{Pattern: \"nomatch\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif out != \"No matches found.\" {\n\t\tt.Fatalf(\"unexpected result: %q\", out)\n\t}\n}\n","start_line":120,"end_line":230,"total_lines":335,"truncated":true}
{"end_line":120,"path":"internal/tools/tools_test.go","start_line":1}
{"path":"internal/tools/tools_test.go","content":"package tools\n\nimport (\n\t\"context\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"strings\"\n\t\"testing\"\n)\n\ntype fakeForgejoActions struct {\n\tcomments []string\n\tassigned []string\n\treviews []string\n}\n\nfunc (f *fakeForgejoActions) Comment(body string) error {\n\tf.comments = append(f.comments, body)\n\treturn nil\n}\nfunc (f *fakeForgejoActions) OpenPullRequest(head, base, title, body string) error { return nil }\nfunc (f *fakeForgejoActions) RequestReview(reviewers []string) error { return nil }\nfunc (f *fakeForgejoActions) AddLabel(name string) error { return nil }\nfunc (f *fakeForgejoActions) RemoveLabel(name string) error { return nil }\nfunc (f *fakeForgejoActions) CloseIssue() error { return nil }\nfunc (f *fakeForgejoActions) ReopenIssue() error { return nil }\nfunc (f *fakeForgejoActions) AssignIssue(agentName string) error {\n\tf.assigned = append(f.assigned, agentName)\n\treturn nil\n}\nfunc (f *fakeForgejoActions) SubmitReview(state, body string) error {\n\tf.reviews = append(f.reviews, state)\n\treturn nil\n}\n\ntype fakeContext struct {\n\tlastCmd string\n\tstdout string\n\tstderr string\n\terr error\n\tfg *fakeForgejoActions\n}\n\nfunc (f *fakeContext) Exec(ctx context.Context, command string) (string, string, error) {\n\tf.lastCmd = command\n\treturn f.stdout, f.stderr, f.err\n}\n\nfunc (f *fakeContext) Forgejo() ForgejoActions {\n\treturn f.fg\n}\n\nfunc TestShellQuote(t *testing.T) {\n\tcases := map[string]string{\n\t\t\"simple\": \"'simple'\",\n\t\t\"it's a dir\": `'it'\\''s a dir'`,\n\t}\n\tfor in, want := range cases {\n\t\tif got := shellQuote(in); got != want {\n\t\t\tt.Errorf(\"shellQuote(%q) = %q, want %q\", in, got, want)\n\t\t}\n\t}\n}\n\nfunc TestReadFileParsesMetaAndContent(t *testing.T) {\n\tfc := \u0026fakeContext{stdout: \"3\\nline one\\nline two\\nline three\\n\"}\n\n\tout, err := readFile(fc, readFileParams{Path: \"src/main.go\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif !strings.Contains(fc.lastCmd, \"/project/src/main.go\") {\n\t\tt.Fatalf(\"expected command to reference /project/src/main.go, got %q\", fc.lastCmd)\n\t}\n\tif !strings.Contains(out, \"line one\") || !strings.Contains(out, `\"total_lines\":3`) {\n\t\tt.Fatalf(\"unexpected result: %s\", out)\n\t}\n\tif strings.Contains(out, `\"truncated\":true`) {\n\t\tt.Fatalf(\"full read should not be truncated: %s\", out)\n\t}\n}\n\nfunc TestCommentToolCallsForgejo(t *testing.T) {\n\tfg := \u0026fakeForgejoActions{}\n\tfc := \u0026fakeContext{fg: fg}\n\n\tif _, err := comment(fc, commentParams{Body: \"hello\"}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif len(fg.comments) != 1 || fg.comments[0] != \"hello\" {\n\t\tt.Fatalf(\"unexpected comments: %v\", fg.comments)\n\t}\n}\n\nfunc TestAssignIssueToolCallsForgejo(t *testing.T) {\n\tfg := \u0026fakeForgejoActions{}\n\tfc := \u0026fakeContext{fg: fg}\n\n\tout, err := assignIssue(fc, assignIssueParams{Agent: \"greg\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif out != \"Assigned to greg\" {\n\t\tt.Fatalf(\"unexpected result: %q\", out)\n\t}\n\tif len(fg.assigned) != 1 || fg.assigned[0] != \"greg\" {\n\t\tt.Fatalf(\"unexpected assignments: %v\", fg.assigned)\n\t}\n}\n\nfunc TestSubmitReviewToolCallsForgejo(t *testing.T) {\n\tfg := \u0026fakeForgejoActions{}\n\tfc := \u0026fakeContext{fg: fg}\n\n\tout, err := submitReview(fc, submitReviewParams{State: \"changes_requested\", Body: \"please fix\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}","start_line":1,"end_line":120,"total_lines":335,"truncated":true}
{"end_line":335,"path":"internal/tools/tools_test.go","start_line":230}
{"path":"internal/tools/tools_test.go","content":"\nfunc TestBashReturnsCombinedOutput(t *testing.T) {\n\tfc := \u0026fakeContext{stdout: \"out\\n\", stderr: \"err\\n\"}\n\n\tout, err := bash(fc, bashParams{Command: \"cmd\"})\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\tif out != \"out\\nerr\\n\" {\n\t\tt.Fatalf(\"unexpected output: %q\", out)\n\t}\n}\n\nfunc TestBashCarriesOutputInError(t *testing.T) {\n\tfc := \u0026fakeContext{\n\t\tstderr: \"ls: cannot access '/nope': No such file or directory\\n\",\n\t\terr: fmt.Errorf(\"command exited with status 2\"),\n\t}\n\n\t_, err := bash(fc, bashParams{Command: \"ls /nope\"})\n\tif err == nil {\n\t\tt.Fatal(\"expected an error for a non-zero exit\")\n\t}\n\tif !strings.Contains(err.Error(), \"exited with status 2\") || !strings.Contains(err.Error(), \"cannot access\") {\n\t\tt.Fatalf(\"expected the exit code and the command's stderr in the error, got: %v\", err)\n\t}\n}\n\nfunc TestBashSilentFailure(t *testing.T) {\n\tfc := \u0026fakeContext{err: fmt.Errorf(\"command exited with status 1\")}\n\n\t_, err := bash(fc, bashParams{Command: \"false\"})\n\tif err == nil {\n\t\tt.Fatal(\"expected an error for a non-zero exit\")\n\t}\n\tif !strings.Contains(err.Error(), \"exited with status 1\") {\n\t\tt.Fatalf(\"unexpected error: %v\", err)\n\t}\n}\n\nfunc TestCombineOutput(t *testing.T) {\n\tcases := []struct {\n\t\tstdout, stderr, want string\n\t}{\n\t\t{\"a\\n\", \"\", \"a\\n\"},\n\t\t{\"\", \"b\\n\", \"b\\n\"},\n\t\t{\"\", \"\", \"\"},\n\t\t{\"a\\n\", \"b\\n\", \"a\\nb\\n\"},\n\t\t{\"a\", \"b\\n\", \"a\\nb\\n\"},\n\t}\n\n\tfor _, c := range cases {\n\t\tif got := combineOutput(c.stdout, c.stderr); got != c.want {\n\t\t\tt.Errorf(\"combineOutput(%q, %q) = %q, want %q\", c.stdout, c.stderr, got, c.want)\n\t\t}\n\t}\n}\n\nfunc TestRegistryRoundTrip(t *testing.T) {\n\tif len(All()) == 0 {\n\t\tt.Fatal(\"expected registered tools\")\n\t}\n\n\tfound := false\n\tfor _, tl := range All() {\n\t\tif tl.Function.Name == \"bash\" {\n\t\t\tfound = true\n\t\t}\n\t}\n\tif !found {\n\t\tt.Fatal(\"expected bash tool to be registered\")\n\t}\n}\n\n// TestToolSchemas guards the wire format of every registered tool: an\n// empty or missing parameters type is rejected by strict servers with\n// \"β is not valid under any of the given schemas\".\nfunc TestToolSchemas(t *testing.T) {\n\tfor _, tool := range All() {\n\t\traw, err := json.Marshal(tool)\n\t\tif err != nil {\n\t\t\tt.Fatalf(\"%s: %v\", tool.Function.Name, err)\n\t\t}\n\n\t\tvar decoded struct {\n\t\t\tFunction struct {\n\t\t\t\tParameters struct {\n\t\t\t\t\tType string `json:\"type\"`\n\t\t\t\t\tProperties map[string]json.RawMessage `json:\"properties\"`\n\t\t\t\t} `json:\"parameters\"`\n\t\t\t} `json:\"function\"`\n\t\t}\n\n\t\tif err := json.Unmarshal(raw, \u0026decoded); err != nil {\n\t\t\tt.Fatalf(\"%s: %v\", tool.Function.Name, err)\n\t\t}\n\n\t\tif decoded.Function.Parameters.Type != \"object\" {\n\t\t\tt.Errorf(\"%s: parameters.type = %q, want \\\"object\\\"\", tool.Function.Name, decoded.Function.Parameters.Type)\n\t\t}\n\n\t\tif decoded.Function.Parameters.Properties == nil {\n\t\t\tt.Errorf(\"%s: parameters.properties missing\", tool.Function.Name)\n\t\t}\n\t}\n}","start_line":230,"end_line":335,"total_lines":335,"truncated":false}
{"query":"func.*Exec|execErr|shellQuote"}
/project/internal/tools/grep_search.go:68: args = append(args, "--include="+shellQuote(params.Glob))
/project/internal/tools/grep_search.go:74: args = append(args, "-e", shellQuote(params.Pattern), shellQuote(target))
/project/internal/tools/grep_search.go:86: return "", fmt.Errorf("grep_search: %w", execErr(err, output, stderr))
/project/internal/tools/remove_file.go:27: stdout, stderr, err := ctx.Exec(context.Background(), "rm -f "+shellQuote(p))
/project/internal/tools/remove_file.go:29: return "", fmt.Errorf("remove %q: %w", params.Path, execErr(err, stdout, stderr))
/project/internal/tools/move_file.go:30: cmd := fmt.Sprintf(`mkdir -p "$(dirname %s)" && mv %s %s`, shellQuote(to), shellQuote(from), shellQuote(to))
/project/internal/tools/move_file.go:34: return "", fmt.Errorf("move %q to %q: %w", params.FromPath, params.ToPath, execErr(err, stdout, stderr))
/project/internal/tools/read_file.go:63: shellQuote(p), from, to)
/project/internal/tools/read_file.go:67: return "", fmt.Errorf("read %q: %w", params.Path, execErr(err, output, stderr))
/project/internal/tools/list_files.go:32: cmd = fmt.Sprintf(`find %s -mindepth 1 -not -path '*/.*' | sort`, shellQuote(p))
/project/internal/tools/list_files.go:34: cmd = fmt.Sprintf(`ls -la %s | tail -n +2`, shellQuote(p))
/project/internal/tools/list_files.go:39: return "", fmt.Errorf("list %q: %w", params.Path, execErr(err, output, stderr))
/project/internal/tools/bash.go:57: return "", execErr(err, stdout, stderr)
/project/internal/tools/shell.go:10:// shellQuote wraps s in single quotes for safe interpolation into a `sh
/project/internal/tools/shell.go:12:func shellQuote(s string) string {
/project/internal/tools/shell.go:52:// execErr wraps an Exec failure with whatever the command printed, so
/project/internal/tools/shell.go:54:func execErr(err error, stdout, stderr string) error {
/project/internal/tools/shell.go:68: output, stderr, err := ctx.Exec(context.Background(), "base64 "+shellQuote(p))
/project/internal/tools/shell.go:70: return nil, execErr(err, output, stderr)
/project/internal/tools/shell.go:87: shellQuote(p), shellQuote(encoded), shellQuote(p))
/project/internal/tools/shell.go:91: return fmt.Errorf("write %q: %w", path, execErr(err, stdout, stderr))
/project/internal/tools/tools_test.go:44:func (f *fakeContext) Exec(ctx context.Context, command string) (string, string, error) {
/project/internal/tools/tools_test.go:53:func TestShellQuote(t *testing.T) {
/project/internal/tools/tools_test.go:59: if got := shellQuote(in); got != want {
/project/internal/tools/tools_test.go:60: t.Errorf("shellQuote(%q) = %q, want %q", in, got, want)
/project/internal/tools/tools_test.go:176: if !strings.HasSuffix(fc.lastCmd, shellQuote("/project/internal/store/store.go")+" | head -n 100") {
/project/internal/tools/tools_test.go:199: if !strings.HasSuffix(fc.lastCmd, shellQuote("/project")+" | head -n 100") {
/project/internal/agentrun/context.go:22:func (c *runContext) Exec(ctx context.Context, command string) (string, string, error) {
/project/internal/agentrun/sandboxgit.go:18:// shellQuote wraps s in single quotes for safe interpolation into a
/project/internal/agentrun/sandboxgit.go:20:func shellQuote(s string) string {
/project/internal/agentrun/sandboxgit.go:31: parts = append(parts, shellQuote(a))
/project/internal/agentrun/loop_test.go:23:func (fakeToolsContext) Exec(context.Context, string) (string, string, error) {
/project/internal/agentrun/docker.go:74:func (d *dockerRuntime) exec(ctx context.Context, containerID, command string) (string, string, int, error) {
/project/internal/agentrun/docker_integration_test.go:17:func TestDockerRuntimeExecRoundTrip(t *testing.T) {
/project/internal/agentrun/docker_integration_test.go:59:func TestDockerRuntimeExecStreams(t *testing.T) {
/project/internal/agentrun/sandboxgit_test.go:9:func TestSandboxShellQuote(t *testing.T) {
/project/internal/agentrun/sandboxgit_test.go:22: if got := shellQuote(in); got != want {
/project/internal/agentrun/sandboxgit_test.go:23: t.Errorf("shellQuote(%q) = %q, want %q", in, got, want)
{"end_line":60,"path":"internal/agentrun/context.go","start_line":1}
{"path":"internal/agentrun/context.go","content":"package agentrun\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\n\t\"github.com/abrander/zoo/internal/tools\"\n)\n\n// runContext implements tools.Context for a single agent run, executing\n// shell commands via docker exec against the run's container, and\n// binding Forgejo actions to the triggering issue/PR.\ntype runContext struct {\n\tdocker *dockerRuntime\n\tcontainerID string\n\tforgejo tools.ForgejoActions\n}\n\n// Exec runs command inside the container via `sh -c` and returns its\n// stdout and stderr as separate strings. A non-zero exit code is an\n// error; the output is still returned so callers can surface it.\nfunc (c *runContext) Exec(ctx context.Context, command string) (string, string, error) {\n\tstdout, stderr, exitCode, err := c.docker.exec(ctx, c.containerID, command)\n\n\tif err != nil {\n\t\treturn stdout, stderr, err\n\t}\n\n\tif exitCode != 0 {\n\t\treturn stdout, stderr, fmt.Errorf(\"command exited with status %d\", exitCode)\n\t}\n\n\treturn stdout, stderr, nil\n}\n\nfunc (c *runContext) Forgejo() tools.ForgejoActions {\n\treturn c.forgejo\n}","start_line":1,"end_line":38,"total_lines":38,"truncated":false}
{"query":"max_results|grep"}
/project/.git/hooks/commit-msg.sample:16:# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"
/project/.git/hooks/commit-msg.sample:23:test "" = "$(grep '^Signed-off-by: ' "$1" |
/project/.git/hooks/sendemail-validate.sample:65:if grep -q "^diff --git " "$1"
/project/.git/hooks/pre-push.sample:44: commit=$(git rev-list -n 1 --grep '^WIP' "$range")
/project/internal/tools/grep_search.go:12:type grepSearchParams struct {
/project/internal/tools/grep_search.go:18: MaxResults int `json:"max_results"`
/project/internal/tools/grep_search.go:23: "grep_search",
/project/internal/tools/grep_search.go:31: tool.AddIntegerProperty("max_results", "Maximum number of matching lines to return (default: 100, 0 means no limit)", false)
/project/internal/tools/grep_search.go:33: Register(tool, grepSearch)
/project/internal/tools/grep_search.go:36:func grepSearch(ctx Context, params grepSearchParams) (string, error) {
/project/internal/tools/grep_search.go:42: // -E makes grep interpret the pattern as an extended regular
/project/internal/tools/grep_search.go:45: // it, grep falls back to BRE, where those are treated literally.
/project/internal/tools/grep_search.go:48: args := []string{"grep", "-Rn", "-I", "-H", "-E"}
/project/internal/tools/grep_search.go:61: // By default the search target is the whole project. grep's
/project/internal/tools/grep_search.go:80: // grep exits non-zero (with empty output) when there are no
/project/internal/tools/grep_search.go:86: return "", fmt.Errorf("grep_search: %w", execErr(err, output, stderr))
/project/internal/tools/grep_search.go:90: // The pipeline's exit status is head's, so a grep failure (e.g.
/project/internal/tools/grep_search.go:94: return "", fmt.Errorf("grep_search: %s", msg)
/project/internal/tools/grep_search.go:104:// fnmatch (and thus grep's --include) understands. A value without
/project/internal/tools/tools_test.go:143:func TestGrepSearchUsesExtendedRegex(t *testing.T) {
/project/internal/tools/tools_test.go:146: out, err := grepSearch(fc, grepSearchParams{Pattern: `foo|bar`})
/project/internal/tools/tools_test.go:150: if !strings.Contains(fc.lastCmd, "grep -Rn -I -H -E") {
/project/internal/tools/tools_test.go:151: t.Fatalf("expected grep to be invoked with -E (extended regex), got %q", fc.lastCmd)
/project/internal/tools/tools_test.go:161:func TestGrepSearchLiteralPathSearchesFileDirectly(t *testing.T) {
/project/internal/tools/tools_test.go:164: out, err := grepSearch(fc, grepSearchParams{
/project/internal/tools/tools_test.go:177: t.Fatalf("expected grep to target the literal path, got %q", fc.lastCmd)
/project/internal/tools/tools_test.go:189:func TestGrepSearchGlobStillUsesInclude(t *testing.T) {
/project/internal/tools/tools_test.go:192: if _, err := grepSearch(fc, grepSearchParams{Pattern: "foo", Glob: "*.go"}); err != nil {
/project/internal/tools/tools_test.go:219:func TestGrepSearchNoMatches(t *testing.T) {
/project/internal/tools/tools_test.go:222: out, err := grepSearch(fc, grepSearchParams{Pattern: "nomatch"})
/project/internal/llm/arguments_test.go:20: {"leaked markup after object", "{\"a\": 1}\n</parameter>\nmax_results>30", `{"a":1}`, true},
/project/internal/llm/arguments_test.go:24: {"pure markup", "<parameter name>max_results>30", "{}", false},
/project/internal/agentrun/system.md:20:- `bash`, `read_file`, `write_file`, `list_files`, `grep_search`,
/project/internal/agentrun/loop_test.go:101: FunctionCall: llm.FunctionCall{Name: "grep_search", Arguments: "</parameter>\nmax_results>30"},
/project/internal/agentrun/loop_test.go:179: want := []string{"1 grep_search </parameter>\nmax_results>30"}
/project/internal/agentrun/loop_test.go:222: if !strings.Contains(err.Error(), "grep_search") || !strings.Contains(err.Error(), "not valid JSON") {
/project/internal/agentrun/docker_integration_test.go:280: out, _, _, err = rt.exec(ctx, containerID, "grep -r super-secret-token /project || true")
/project/internal/agentrun/docker_integration_test.go:282: t.Fatalf("grep: %v", err)
{"command":"cd /project && git status && git log --oneline -3 && git branch --show-current"}
On branch issue-68-greg nothing to commit, working tree clean f443489 Merge pull request 'grep_search: treat a wildcard-free glob as a literal file path' (#66) from issue-65-greg into main 474ef42 grep_search: treat a wildcard-free glob as a literal file path 081d4bd Merge pull request 'livelog: open reasoning/content blocks lazily, on first real text' (#63) from issue-62-greg into main issue-68-greg
{"end_line":60,"path":"internal/tools/shell.go","start_line":1}
{"path":"internal/tools/shell.go","content":"package tools\n\nimport (\n\t\"context\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"strings\"\n)\n\n// shellQuote wraps s in single quotes for safe interpolation into a `sh\n// -c` command string, escaping any embedded single quotes.\nfunc shellQuote(s string) string {\n\treturn \"'\" + strings.ReplaceAll(s, \"'\", `'\\''`) + \"'\"\n}\n\nfunc resolvePath(path string) string {\n\tif path == \"\" {\n\t\treturn \"/project\"\n\t}\n\n\tif path == \".\" || path == \"./\" {\n\t\treturn \"/project\"\n\t}\n\n\tif strings.HasPrefix(path, \"/project/\") {\n\t\treturn path\n\t}\n\n\tif !strings.HasPrefix(path, \"/\") {\n\t\treturn \"/project/\" + strings.TrimPrefix(path, \"/\")\n\t}\n\n\treturn path\n}\n\n// combineOutput merges a command's stdout and stderr into the single\n// string a tool result presents: stdout first, then stderr if the\n// command wrote to both.\nfunc combineOutput(stdout, stderr string) string {\n\tswitch {\n\tcase stdout == \"\":\n\t\treturn stderr\n\tcase stderr == \"\":\n\t\treturn stdout\n\tcase strings.HasSuffix(stdout, \"\\n\"):\n\t\treturn stdout + stderr\n\tdefault:\n\t\treturn stdout + \"\\n\" + stderr\n\t}\n}\n\n// execErr wraps an Exec failure with whatever the command printed, so\n// a non-zero exit code doesn't swallow the error message.\nfunc execErr(err error, stdout, stderr string) error {\n\tif out := strings.TrimSpace(combineOutput(stdout, stderr)); out != \"\" {\n\t\treturn fmt.Errorf(\"%w: %s\", err, out)\n\t}\n\n\treturn err\n}","start_line":1,"end_line":60,"total_lines":95,"truncated":true}
{"find":"\ttool.AddIntegerProperty(\"max_results\", \"Maximum number of matching lines to return (default: 100, 0 means no limit)\", false)","path":"internal/tools/grep_search.go","replace":"\ttool.AddIntegerProperty(\"max_results\", \"Maximum number of matching lines to return per file (default: 100)\", false)"}
1 occurrences replaced
{"find":"\tif params.ContextAfter > 0 {\n\t\targs = append(args, \"-A\", strconv.Itoa(params.ContextAfter))\n\t}\n\n\t// By default the search target is the whole project. grep's","path":"internal/tools/grep_search.go","replace":"\tif params.ContextAfter > 0 {\n\t\targs = append(args, \"-A\", strconv.Itoa(params.ContextAfter))\n\t}\n\n\t// --max-count caps the number of matching lines, which is what\n\t// max_results promises. A `head -n` pipe would instead cap raw\n\t// output lines, and with -A/-B context a single match spans\n\t// several lines, so matches would be silently dropped. Note the\n\t// cap is per file: that is how grep counts.\n\targs = append(args, \"--max-count=\"+strconv.Itoa(maxResults))\n\n\t// By default the search target is the whole project. grep's"}
1 occurrences replaced
{"find":"\targs = append(args, \"-e\", shellQuote(params.Pattern), shellQuote(target))\n\n\tcmd := strings.Join(args, \" \") + fmt.Sprintf(\" | head -n %d\", maxResults)\n\n\toutput, stderr, err := ctx.Exec(context.Background(), cmd)","path":"internal/tools/grep_search.go","replace":"\targs = append(args, \"-e\", shellQuote(params.Pattern), shellQuote(target))\n\n\toutput, stderr, err := ctx.Exec(context.Background(), strings.Join(args, \" \"))"}
1 occurrences replaced
{"find":"\tif strings.TrimSpace(output) == \"\" {\n\t\t// The pipeline's exit status is head's, so a grep failure (e.g.\n\t\t// a bad regex) doesn't surface as an error; its diagnostic is on\n\t\t// stderr instead of in the match list.\n\t\tif msg := strings.TrimSpace(stderr); msg != \"\" {","path":"internal/tools/grep_search.go","replace":"\tif strings.TrimSpace(output) == \"\" {\n\t\t// Defensive: grep exits 0 only after printing at least one\n\t\t// match, so an empty result here is unexpected; surface any\n\t\t// diagnostic rather than pretending the search succeeded.\n\t\tif msg := strings.TrimSpace(stderr); msg != \"\" {"}
1 occurrences replaced
{"end_line":115,"path":"internal/tools/grep_search.go","start_line":36}
{"path":"internal/tools/grep_search.go","content":"func grepSearch(ctx Context, params grepSearchParams) (string, error) {\n\tmaxResults := params.MaxResults\n\tif maxResults == 0 {\n\t\tmaxResults = 100\n\t}\n\n\t// -E makes grep interpret the pattern as an extended regular\n\t// expression (ERE), which is the \"real\" regex flavor the tool\n\t// promises: |, +, ?, (), {} all work as metacharacters. Without\n\t// it, grep falls back to BRE, where those are treated literally.\n\t// -H keeps the filename in the output even when the search target\n\t// is a single file, so results look the same as directory searches.\n\targs := []string{\"grep\", \"-Rn\", \"-I\", \"-H\", \"-E\"}\n\tif !params.CaseSensitive {\n\t\targs = append(args, \"-i\")\n\t}\n\n\tif params.ContextBefore \u003e 0 {\n\t\targs = append(args, \"-B\", strconv.Itoa(params.ContextBefore))\n\t}\n\n\tif params.ContextAfter \u003e 0 {\n\t\targs = append(args, \"-A\", strconv.Itoa(params.ContextAfter))\n\t}\n\n\t// --max-count caps the number of matching lines, which is what\n\t// max_results promises. A `head -n` pipe would instead cap raw\n\t// output lines, and with -A/-B context a single match spans\n\t// several lines, so matches would be silently dropped. Note the\n\t// cap is per file: that is how grep counts.\n\targs = append(args, \"--max-count=\"+strconv.Itoa(maxResults))\n\n\t// By default the search target is the whole project. grep's\n\t// --include matches basenames against a glob, so a literal file\n\t// path (no wildcards) passed as the glob would never match;\n\t// treat such values as a path and search that file directly.\n\ttarget := \"/project\"\n\tif params.Glob != \"\" {\n\t\tif isGlobPattern(params.Glob) {\n\t\t\targs = append(args, \"--include=\"+shellQuote(params.Glob))\n\t\t} else {\n\t\t\ttarget = resolvePath(params.Glob)\n\t\t}\n\t}\n\n\targs = append(args, \"-e\", shellQuote(params.Pattern), shellQuote(target))\n\n\toutput, stderr, err := ctx.Exec(context.Background(), strings.Join(args, \" \"))\n\tif err != nil {\n\t\t// grep exits non-zero (with empty output) when there are no\n\t\t// matches; that's a normal result, not a tool failure.\n\t\tif strings.TrimSpace(output) == \"\" \u0026\u0026 strings.TrimSpace(stderr) == \"\" {\n\t\t\treturn \"No matches found.\", nil\n\t\t}\n\n\t\treturn \"\", fmt.Errorf(\"grep_search: %w\", execErr(err, output, stderr))\n\t}\n\n\tif strings.TrimSpace(output) == \"\" {\n\t\t// Defensive: grep exits 0 only after printing at least one\n\t\t// match, so an empty result here is unexpected; surface any\n\t\t// diagnostic rather than pretending the search succeeded.\n\t\tif msg := strings.TrimSpace(stderr); msg != \"\" {\n\t\t\treturn \"\", fmt.Errorf(\"grep_search: %s\", msg)\n\t\t}\n\n\t\treturn \"No matches found.\", nil\n\t}\n\n\treturn output, nil\n}\n\n// isGlobPattern reports whether s contains the glob metacharacters\n// fnmatch (and thus grep's --include) understands. A value without\n// them is a literal path, which --include would never match since it\n// globs basenames, not full paths.\nfunc isGlobPattern(s string) bool {\n\treturn strings.ContainsAny(s, \"*?[\")\n}","start_line":36,"end_line":114,"total_lines":114,"truncated":false}
{"find":"\tif !strings.HasSuffix(fc.lastCmd, shellQuote(\"/project/internal/store/store.go\")+\" | head -n 100\") {\n\t\tt.Fatalf(\"expected grep to target the literal path, got %q\", fc.lastCmd)\n\t}","path":"internal/tools/tools_test.go","replace":"\tif !strings.HasSuffix(fc.lastCmd, shellQuote(\"/project/internal/store/store.go\")) {\n\t\tt.Fatalf(\"expected grep to target the literal path, got %q\", fc.lastCmd)\n\t}"}
1 occurrences replaced
{"find":"\tif !strings.HasSuffix(fc.lastCmd, shellQuote(\"/project\")+\" | head -n 100\") {\n\t\tt.Fatalf(\"expected glob search to target /project, got %q\", fc.lastCmd)\n\t}\n}","path":"internal/tools/tools_test.go","replace":"\tif !strings.HasSuffix(fc.lastCmd, shellQuote(\"/project\")) {\n\t\tt.Fatalf(\"expected glob search to target /project, got %q\", fc.lastCmd)\n\t}\n}\n\n// max_results must cap the number of matching lines via grep's\n// --max-count, not the number of raw output lines via a head pipe:\n// with context lines, a single match spans several lines, so head\n// would silently drop matches.\nfunc TestGrepSearchLimitsMatchesNotLines(t *testing.T) {\n\tfc := &fakeContext{stdout: \"/project/foo.go:1:foo\\n\"}\n\n\tif _, err := grepSearch(fc, grepSearchParams{Pattern: \"foo\", MaxResults: 5, ContextAfter: 2}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif !strings.Contains(fc.lastCmd, \"--max-count=5\") {\n\t\tt.Fatalf(\"expected max_results to be passed to grep as --max-count, got %q\", fc.lastCmd)\n\t}\n\tif strings.Contains(fc.lastCmd, \"head\") {\n\t\tt.Fatalf(\"expected no head pipe in the command, got %q\", fc.lastCmd)\n\t}\n}\n\n// An omitted max_results (zero) falls back to the default of 100.\nfunc TestGrepSearchDefaultMaxResults(t *testing.T) {\n\tfc := &fakeContext{stdout: \"/project/foo.go:1:foo\\n\"}\n\n\tif _, err := grepSearch(fc, grepSearchParams{Pattern: \"foo\"}); err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif !strings.Contains(fc.lastCmd, \"--max-count=100\") {\n\t\tt.Fatalf(\"expected the default --max-count=100, got %q\", fc.lastCmd)\n\t}\n}"}
1 occurrences replaced
{"command":"cd /project && go build ./... && go vet ./internal/tools/ && go test ./internal/tools/ -run 'GrepSearch|IsGlobPattern' -v 2>&1 | tail -30","timeout":180}
=== RUN TestGrepSearchUsesExtendedRegex --- PASS: TestGrepSearchUsesExtendedRegex (0.00s) === RUN TestGrepSearchLiteralPathSearchesFileDirectly --- PASS: TestGrepSearchLiteralPathSearchesFileDirectly (0.00s) === RUN TestGrepSearchGlobStillUsesInclude --- PASS: TestGrepSearchGlobStillUsesInclude (0.00s) === RUN TestGrepSearchLimitsMatchesNotLines --- PASS: TestGrepSearchLimitsMatchesNotLines (0.00s) === RUN TestGrepSearchDefaultMaxResults --- PASS: TestGrepSearchDefaultMaxResults (0.00s) === RUN TestIsGlobPattern --- PASS: TestIsGlobPattern (0.00s) === RUN TestGrepSearchNoMatches --- PASS: TestGrepSearchNoMatches (0.00s) PASS ok github.com/abrander/zoo/internal/tools 0.003s go: downloading github.com/docker/docker v28.5.2+incompatible go: downloading github.com/hashicorp/hcl/v2 v2.24.0 go: downloading codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 go: downloading github.com/google/uuid v1.6.0 go: downloading modernc.org/sqlite v1.56.0 go: downloading github.com/docker/go-connections v0.8.1 go: downloading github.com/docker/go-units v0.5.0 go: downloading github.com/moby/docker-image-spec v1.3.1 go: downloading github.com/opencontainers/image-spec v1.1.1 go: downloading github.com/containerd/errdefs v1.0.0 go: downloading github.com/containerd/errdefs/pkg v0.3.0 go: downloading github.com/distribution/reference v0.6.0 go: downloading github.com/opencontainers/go-digest v1.0.0 go: downloading github.com/pkg/errors v0.9.1 go: downloading go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 go: downloading go.opentelemetry.io/otel/trace v1.45.0 go: downloading go.opentelemetry.io/otel v1.45.0 go: downloading github.com/42wim/httpsig v1.2.3 go: downloading github.com/go-fed/httpsig v1.1.0 go: downloading github.com/hashicorp/go-version v1.7.0 go: downloading golang.org/x/crypto v0.39.0 go: downloading github.com/felixge/httpsnoop v1.1.0 go: downloading go.opentelemetry.io/otel/metric v1.45.0 go: downloading github.com/agext/levenshtein v1.2.1 go: downloading github.com/apparentlymart/go-textseg/v15 v15.0.0 go: downloading github.com/mitchellh/go-wordwrap v1.0.1 go: downloading github.com/zclconf/go-cty v1.16.3 go: downloading github.com/go-logr/logr v1.4.4 go: downloading golang.org/x/sys v0.47.0 go: downloading github.com/google/go-cmp v0.7.0 go: downloading github.com/go-logr/stdr v1.2.2 go: downloading go.opentelemetry.io/auto/sdk v1.2.1 go: downloading github.com/cespare/xxhash/v2 v2.3.0 go: downloading golang.org/x/text v0.40.0 go: downloading modernc.org/libc v1.74.4 go: downloading github.com/dustin/go-humanize v1.0.1 go: downloading modernc.org/mathutil v1.7.1 go: downloading modernc.org/memory v1.11.0 go: downloading github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec
{"command":"cd /project && go test ./... 2>&1 | grep -v \"^go: downloading\" | tail -20","timeout":300}
? github.com/abrander/zoo/cmd/zoo [no test files] ok github.com/abrander/zoo/internal/agentrun 0.025s ok github.com/abrander/zoo/internal/config 0.006s ok github.com/abrander/zoo/internal/forgejo 1.506s ok github.com/abrander/zoo/internal/livelog 0.052s ok github.com/abrander/zoo/internal/llm 0.003s ok github.com/abrander/zoo/internal/scheduler 2.885s ok github.com/abrander/zoo/internal/store 1.547s ok github.com/abrander/zoo/internal/tools 0.003s ok github.com/abrander/zoo/internal/web 1.182s
{"command":"cd /tmp && mkdir -p greptest && cd greptest && printf 'match1\\nx\\nx\\nmatch2\\nx\\nx\\nmatch3\\nx\\nx\\nmatch4\\nx\\nx\\nmatch5\\n' > f.txt && echo '--- old behavior (head -n 10):' && grep -Rn -I -H -E -i -A 2 --max-count=100 -e 'match' f.txt | head -n 10 && echo '--- new behavior (--max-count=2):' && grep -Rn -I -H -E -i -A 2 --max-count=2 -e 'match' f.txt; echo \"exit=$?\""}
--- old behavior (head -n 10): f.txt:1:match1 f.txt-2-x f.txt-3-x f.txt:4:match2 f.txt-5-x f.txt-6-x f.txt:7:match3 f.txt-8-x f.txt-9-x f.txt:10:match4 --- new behavior (--max-count=2): f.txt:1:match1 f.txt-2-x f.txt-3-x f.txt:4:match2 f.txt-5-x f.txt-6-x exit=0
{"command":"cd /project && git diff && rm -rf /tmp/greptest"}
diff --git a/internal/tools/grep_search.go b/internal/tools/grep_search.go
index 062220f..2de9455 100644
--- a/internal/tools/grep_search.go
+++ b/internal/tools/grep_search.go
@@ -28,7 +28,7 @@ func init() {
tool.AddBooleanProperty("case_sensitive", "Whether the search should be case sensitive (default: false)", false)
tool.AddIntegerProperty("context_before", "Number of lines of context to show before each match (default: 0)", false)
tool.AddIntegerProperty("context_after", "Number of lines of context to show after each match (default: 0)", false)
- tool.AddIntegerProperty("max_results", "Maximum number of matching lines to return (default: 100, 0 means no limit)", false)
+ tool.AddIntegerProperty("max_results", "Maximum number of matching lines to return per file (default: 100)", false)
Register(tool, grepSearch)
}
@@ -58,6 +58,13 @@ func grepSearch(ctx Context, params grepSearchParams) (string, error) {
args = append(args, "-A", strconv.Itoa(params.ContextAfter))
}
+ // --max-count caps the number of matching lines, which is what
+ // max_results promises. A `head -n` pipe would instead cap raw
+ // output lines, and with -A/-B context a single match spans
+ // several lines, so matches would be silently dropped. Note the
+ // cap is per file: that is how grep counts.
+ args = append(args, "--max-count="+strconv.Itoa(maxResults))
+
// By default the search target is the whole project. grep's
// --include matches basenames against a glob, so a literal file
// path (no wildcards) passed as the glob would never match;
@@ -73,9 +80,7 @@ func grepSearch(ctx Context, params grepSearchParams) (string, error) {
args = append(args, "-e", shellQuote(params.Pattern), shellQuote(target))
- cmd := strings.Join(args, " ") + fmt.Sprintf(" | head -n %d", maxResults)
-
- output, stderr, err := ctx.Exec(context.Background(), cmd)
+ output, stderr, err := ctx.Exec(context.Background(), strings.Join(args, " "))
if err != nil {
// grep exits non-zero (with empty output) when there are no
// matches; that's a normal result, not a tool failure.
@@ -87,9 +92,9 @@ func grepSearch(ctx Context, params grepSearchParams) (string, error) {
}
if strings.TrimSpace(output) == "" {
- // The pipeline's exit status is head's, so a grep failure (e.g.
- // a bad regex) doesn't surface as an error; its diagnostic is on
- // stderr instead of in the match list.
+ // Defensive: grep exits 0 only after printing at least one
+ // match, so an empty result here is unexpected; surface any
+ // diagnostic rather than pretending the search succeeded.
if msg := strings.TrimSpace(stderr); msg != "" {
return "", fmt.Errorf("grep_search: %s", msg)
}
diff --git a/internal/tools/tools_test.go b/internal/tools/tools_test.go
index beee4ed..babde4c 100644
--- a/internal/tools/tools_test.go
+++ b/internal/tools/tools_test.go
@@ -173,7 +173,7 @@ func TestGrepSearchLiteralPathSearchesFileDirectly(t *testing.T) {
if strings.Contains(fc.lastCmd, "--include") {
t.Fatalf("literal path must not be passed to --include, got %q", fc.lastCmd)
}
- if !strings.HasSuffix(fc.lastCmd, shellQuote("/project/internal/store/store.go")+" | head -n 100") {
+ if !strings.HasSuffix(fc.lastCmd, shellQuote("/project/internal/store/store.go")) {
t.Fatalf("expected grep to target the literal path, got %q", fc.lastCmd)
}
if !strings.Contains(fc.lastCmd, "-A 4") {
@@ -196,11 +196,43 @@ func TestGrepSearchGlobStillUsesInclude(t *testing.T) {
if !strings.Contains(fc.lastCmd, "--include='*.go'") {
t.Fatalf("expected glob to be passed to --include, got %q", fc.lastCmd)
}
- if !strings.HasSuffix(fc.lastCmd, shellQuote("/project")+" | head -n 100") {
+ if !strings.HasSuffix(fc.lastCmd, shellQuote("/project")) {
t.Fatalf("expected glob search to target /project, got %q", fc.lastCmd)
}
}
+// max_results must cap the number of matching lines via grep's
+// --max-count, not the number of raw output lines via a head pipe:
+// with context lines, a single match spans several lines, so head
+// would silently drop matches.
+func TestGrepSearchLimitsMatchesNotLines(t *testing.T) {
+ fc := &fakeContext{stdout: "/project/foo.go:1:foo\n"}
+
+ if _, err := grepSearch(fc, grepSearchParams{Pattern: "foo", MaxResults: 5, ContextAfter: 2}); err != nil {
+ t.Fatal(err)
+ }
+
+ if !strings.Contains(fc.lastCmd, "--max-count=5") {
+ t.Fatalf("expected max_results to be passed to grep as --max-count, got %q", fc.lastCmd)
+ }
+ if strings.Contains(fc.lastCmd, "head") {
+ t.Fatalf("expected no head pipe in the command, got %q", fc.lastCmd)
+ }
+}
+
+// An omitted max_results (zero) falls back to the default of 100.
+func TestGrepSearchDefaultMaxResults(t *testing.T) {
+ fc := &fakeContext{stdout: "/project/foo.go:1:foo\n"}
+
+ if _, err := grepSearch(fc, grepSearchParams{Pattern: "foo"}); err != nil {
+ t.Fatal(err)
+ }
+
+ if !strings.Contains(fc.lastCmd, "--max-count=100") {
+ t.Fatalf("expected the default --max-count=100, got %q", fc.lastCmd)
+ }
+}
+
func TestIsGlobPattern(t *testing.T) {
cases := map[string]bool{
"*.go": true,
{"command":"cd /project && git add internal/tools/grep_search.go internal/tools/tools_test.go && git commit -m \"grep_search: cap matches with --max-count instead of head -n\n\nmax_results was implemented as a 'head -n' pipe, which caps raw output\nlines rather than matching lines. With -A/-B context a single match\nspans several lines, so results were silently truncated mid-match.\n\nUse grep's native --max-count instead, which stops a file after NUM\nmatching lines (emitting their trailing context). Also fix the\nmax_results description: 0 is the unset sentinel that maps to the\ndefault of 100 (passing 0 to --max-count would stop grep immediately),\nand the cap is per file, as grep counts it.\n\nFixes #68\" && git push origin issue-68-greg","timeout":120}
[issue-68-greg 41444dd] grep_search: cap matches with --max-count instead of head -n 2 files changed, 46 insertions(+), 9 deletions(-) To https://code.stdio.dk/abrander/zoo.git * [new branch] issue-68-greg -> issue-68-greg
{"base":"main","body":"## What\n\nFixes #68: `grep_search`'s `max_results` was implemented as a `| head -n N` pipe, which caps **raw output lines** instead of **matching lines**. With `context_before`/`context_after`, a single match spans several lines (match + context + `--` separators), so `head` silently dropped matches β e.g. `max_results=10` with `-A 2` returned only 3 full matches plus a truncated 4th.\n\n## Changes\n\n- **`internal/tools/grep_search.go`**\n - Replaced the `head -n` pipe with grep's native `--max-count=N`, which stops a file after N *matching* lines (still emitting their trailing context).\n - Updated the `max_results` description: it now says \"per file\" (that's how grep counts with `--max-count`), and drops the false \"0 means no limit\" claim β 0 is the unset sentinel that maps to the default of 100, and passing 0 to `--max-count` would make grep stop immediately without reading input.\n - Updated the now-stale comment about the pipeline's exit status (without the pipe, a grep failure like a bad regex surfaces directly as a non-zero exit with its stderr diagnostic).\n- **`internal/tools/tools_test.go`**\n - Updated the two tests that asserted the old `| head -n 100` suffix.\n - Added `TestGrepSearchLimitsMatchesNotLines` (custom `max_results` β `--max-count=5`, no `head` in the command) and `TestGrepSearchDefaultMaxResults` (omitted β `--max-count=100`).\n\n## Verification\n\n- `go build ./...`, `go vet`, and the full `go test ./...` suite pass.\n- Manual check against real grep: with `-A 2` and 5 matches in a file, the old `head -n 10` returned 3 matches (4th truncated); `--max-count=2` returns exactly 2 matches with full context, exit 0.\n\n## Note\n\n`--max-count` is inherently **per-file** (that's grep's semantics), so a recursive search across many files can return up to N matches *per file*. That's the behavior the issue asked for; if a strict global cap is ever wanted, it would need post-processing in Go rather than a shell flag.","head":"issue-68-greg","title":"grep_search: cap matches with --max-count instead of head -n"}
Pull request opened
{"body":"Fixed β PR is up. Replaced the `| head -n N` pipe with grep's native `--max-count=N`, as suggested.\n\n**What was wrong:** `head -n` caps raw output *lines*. With `context_before`/`context_after`, one match spans several lines, so the limit silently dropped matches β I verified against real grep: with `-A 2` and 5 matches, `head -n 10` returned only 3 full matches plus a truncated 4th. `--max-count` stops a file after N *matching* lines (still emitting their trailing context), which is the promised semantics.\n\n**Also fixed while in there:**\n- The `max_results` description claimed \"0 means no limit\", but 0 is the unset sentinel that maps to the default of 100 (and passing 0 to `--max-count` would make grep stop immediately without reading anything). The description now says \"per file (default: 100)\" β per file because that's how grep counts with `--max-count`.\n- A stale comment about the pipeline's exit status being `head`'s; without the pipe, a grep failure (e.g. bad regex) surfaces directly as a non-zero exit with its stderr diagnostic.\n\n**Tests:** updated the two tests asserting the old `head` suffix, added `TestGrepSearchLimitsMatchesNotLines` and `TestGrepSearchDefaultMaxResults`. Full `go test ./...` passes.\n\nOne caveat worth knowing: `--max-count` is per-file by grep's design, so a recursive search can return up to N matches *per file*. If you ever want a strict global cap, that would need post-processing in Go rather than a shell flag β happy to do that as a follow-up if you'd prefer."}
Comment posted