Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions e2e/testdata/cassettes/TestExec_Anthropic_ToolCall.yaml

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions e2e/testdata/cassettes/TestExec_Gemini_ToolCall.yaml

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions e2e/testdata/cassettes/TestExec_Mistral_ToolCall.yaml

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions e2e/testdata/cassettes/TestExec_OpenAI_HideToolCalls.yaml

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions e2e/testdata/cassettes/TestExec_OpenAI_ToolCall.yaml

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interactions:
proto_minor: 1
content_length: 0
host: api.openai.com
body: '{"input":[{"content":[{"text":"You are a knowledgeable assistant that can write test files.","type":"input_text"}],"role":"system"},{"content":[{"text":"## Filesystem Tools\n\n- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","type":"input_text"}],"role":"system"},{"content":"Create a hello.txt file with \"Hello, World!\" content. Try only once. On error, exit without further message.","role":"user"}],"model":"gpt-5-mini","reasoning":{"summary":"detailed"},"tools":[{"strict":true,"parameters":{"additionalProperties":false,"properties":{"content":{"description":"The content to write to the file","type":"string"},"path":{"description":"The file path to write","type":"string"}},"required":["content","path"],"type":"object"},"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","type":"function"}],"stream":true}'
body: '{"input":[{"content":[{"text":"You are a knowledgeable assistant that can write test files.","type":"input_text"}],"role":"system"},{"content":[{"text":"## Filesystem Tools\n\n- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\n- Prefer read_multiple_files over sequential read_file calls\n- Use glob_files to find files by name pattern (e.g. **/*.go, src/**/*.ts)\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","type":"input_text"}],"role":"system"},{"content":"Create a hello.txt file with \"Hello, World!\" content. Try only once. On error, exit without further message.","role":"user"}],"model":"gpt-5-mini","reasoning":{"summary":"detailed"},"tools":[{"strict":true,"parameters":{"additionalProperties":false,"properties":{"content":{"description":"The content to write to the file","type":"string"},"path":{"description":"The file path to write","type":"string"}},"required":["content","path"],"type":"object"},"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","type":"function"}],"stream":true}'
url: https://api.openai.com/v1/responses
method: POST
response:
Expand Down Expand Up @@ -622,7 +622,7 @@ interactions:
proto_minor: 1
content_length: 0
host: api.openai.com
body: '{"input":[{"content":[{"text":"You are a knowledgeable assistant that can write test files.","type":"input_text"}],"role":"system"},{"content":[{"text":"## Filesystem Tools\n\n- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","type":"input_text"}],"role":"system"},{"content":"Create a hello.txt file with \"Hello, World!\" content. Try only once. On error, exit without further message.","role":"user"},{"arguments":"{\"content\":\"Hello, World!\",\"path\":\"hello.txt\"}","call_id":"call_GdIrV330GhotzkZcKWdlNbPV","name":"write_file","type":"function_call"},{"call_id":"call_GdIrV330GhotzkZcKWdlNbPV","output":"The user rejected the tool call.","type":"function_call_output"}],"model":"gpt-5-mini","reasoning":{"summary":"detailed"},"tools":[{"strict":true,"parameters":{"additionalProperties":false,"properties":{"content":{"description":"The content to write to the file","type":"string"},"path":{"description":"The file path to write","type":"string"}},"required":["content","path"],"type":"object"},"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","type":"function"}],"stream":true}'
body: '{"input":[{"content":[{"text":"You are a knowledgeable assistant that can write test files.","type":"input_text"}],"role":"system"},{"content":[{"text":"## Filesystem Tools\n\n- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\n- Prefer read_multiple_files over sequential read_file calls\n- Use glob_files to find files by name pattern (e.g. **/*.go, src/**/*.ts)\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output","type":"input_text"}],"role":"system"},{"content":"Create a hello.txt file with \"Hello, World!\" content. Try only once. On error, exit without further message.","role":"user"},{"arguments":"{\"content\":\"Hello, World!\",\"path\":\"hello.txt\"}","call_id":"call_GdIrV330GhotzkZcKWdlNbPV","name":"write_file","type":"function_call"},{"call_id":"call_GdIrV330GhotzkZcKWdlNbPV","output":"The user rejected the tool call.","type":"function_call_output"}],"model":"gpt-5-mini","reasoning":{"summary":"detailed"},"tools":[{"strict":true,"parameters":{"additionalProperties":false,"properties":{"content":{"description":"The content to write to the file","type":"string"},"path":{"description":"The file path to write","type":"string"}},"required":["content","path"],"type":"object"},"name":"write_file","description":"Create a new file or completely overwrite an existing file with new content.","type":"function"}],"stream":true}'
url: https://api.openai.com/v1/responses
method: POST
response:
Expand Down
110 changes: 110 additions & 0 deletions pkg/tools/builtin/filesystem.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@ import (
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"

"github.com/bmatcuk/doublestar/v4"

"github.com/docker/docker-agent/pkg/chat"
"github.com/docker/docker-agent/pkg/fsx"
"github.com/docker/docker-agent/pkg/shellpath"
Expand All @@ -31,6 +34,7 @@ const (
ToolNameSearchFilesContent = "search_files_content"
ToolNameMkdir = "create_directory"
ToolNameRmdir = "remove_directory"
ToolNameGlobFiles = "glob_files"
)

// PostEditConfig represents a post-edit command configuration
Expand Down Expand Up @@ -87,6 +91,7 @@ func (t *FilesystemTool) Instructions() string {

- Relative paths resolve from the working directory; absolute paths and ".." work as expected
- Prefer read_multiple_files over sequential read_file calls
- Use glob_files to find files by name pattern (e.g. **/*.go, src/**/*.ts)
- Use search_files_content to locate code or text across files
- Use exclude patterns in searches and max_depth in directory_tree to limit output`
}
Expand Down Expand Up @@ -125,6 +130,16 @@ type ListDirectoryArgs struct {
Path string `json:"path" jsonschema:"The directory path to list"`
}

type GlobFilesArgs struct {
Pattern string `json:"pattern" jsonschema:"The glob pattern to match files against (e.g. **/*.go, src/**/*.ts)"`
Path string `json:"path,omitempty" jsonschema:"The directory to search in (defaults to working directory)"`
}

type GlobFilesMeta struct {
FileCount int `json:"fileCount"`
Truncated bool `json:"truncated"`
}

type CreateDirectoryArgs struct {
Paths []string `json:"paths" jsonschema:"Array of directory paths to create"`
}
Expand Down Expand Up @@ -336,6 +351,19 @@ func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) {
Title: "Remove Directory",
},
},
{
Name: ToolNameGlobFiles,
Category: "filesystem",
Description: "Find files by glob pattern. Returns matching file paths sorted by modification time. Supports patterns like **/*.go, src/**/*.ts, *.json.",
Parameters: tools.MustSchemaFor[GlobFilesArgs](),
OutputSchema: tools.MustSchemaFor[string](),
Handler: tools.NewHandler(t.handleGlobFiles),
Annotations: tools.ToolAnnotations{
ReadOnlyHint: true,
Title: "Glob Files",
},
AddDescriptionParameter: true,
},
}, nil
}

Expand Down Expand Up @@ -811,6 +839,88 @@ func (t *FilesystemTool) handleSearchFilesContent(_ context.Context, args Search
}, nil
}

func (t *FilesystemTool) handleGlobFiles(_ context.Context, args GlobFilesArgs) (*tools.ToolCallResult, error) {
searchDir := t.resolvePath(args.Path)

info, err := os.Stat(searchDir)
if err != nil {
return tools.ResultError(fmt.Sprintf("Error accessing directory: %s", err)), nil
}
if !info.IsDir() {
return tools.ResultError("Path is not a directory: " + args.Path), nil
}

// Build the full glob pattern relative to the search directory
fullPattern := filepath.Join(searchDir, args.Pattern)

matches, err := doublestar.FilepathGlob(fullPattern)
if err != nil {
return tools.ResultError(fmt.Sprintf("Invalid glob pattern: %s", err)), nil
}

// Filter ignored paths and collect file info for sorting
type fileEntry struct {
relPath string
modTime int64
}
var entries []fileEntry

for _, match := range matches {
if t.shouldIgnorePath(match) {
continue
}

fi, err := os.Stat(match)
if err != nil {
continue
}
// Only return files, not directories
if fi.IsDir() {
continue
}

relPath, err := filepath.Rel(searchDir, match)
if err != nil {
relPath = match
}

entries = append(entries, fileEntry{relPath: relPath, modTime: fi.ModTime().UnixNano()})
}

// Sort by modification time, most recent first
sort.Slice(entries, func(i, j int) bool {
return entries[i].modTime > entries[j].modTime
})

truncated := false
if len(entries) > maxFiles {
entries = entries[:maxFiles]
truncated = true
}

meta := GlobFilesMeta{
FileCount: len(entries),
Truncated: truncated,
}

if len(entries) == 0 {
return &tools.ToolCallResult{
Output: "No files matched the pattern",
Meta: meta,
}, nil
}

var result strings.Builder
for _, e := range entries {
fmt.Fprintln(&result, e.relPath)
}

return &tools.ToolCallResult{
Output: result.String(),
Meta: meta,
}, nil
}

func (t *FilesystemTool) handleWriteFile(ctx context.Context, args WriteFileArgs) (*tools.ToolCallResult, error) {
resolvedPath := t.resolvePath(args.Path)

Expand Down
2 changes: 2 additions & 0 deletions pkg/tui/components/tool/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"github.com/docker/docker-agent/pkg/tui/components/tool/defaulttool"
"github.com/docker/docker-agent/pkg/tui/components/tool/directorytree"
"github.com/docker/docker-agent/pkg/tui/components/tool/editfile"
"github.com/docker/docker-agent/pkg/tui/components/tool/globfiles"
"github.com/docker/docker-agent/pkg/tui/components/tool/handoff"
"github.com/docker/docker-agent/pkg/tui/components/tool/listdirectory"
"github.com/docker/docker-agent/pkg/tui/components/tool/readfile"
Expand Down Expand Up @@ -71,6 +72,7 @@ func newDefaultRegistry() *Registry {
{[]string{builtin.ToolNameListDirectory}, listdirectory.New},
{[]string{builtin.ToolNameDirectoryTree}, directorytree.New},
{[]string{builtin.ToolNameSearchFilesContent}, searchfilescontent.New},
{[]string{builtin.ToolNameGlobFiles}, globfiles.New},
{[]string{builtin.ToolNameShell}, shell.New},
{[]string{builtin.ToolNameFetch, "category:api"}, api.New},
{
Expand Down
60 changes: 60 additions & 0 deletions pkg/tui/components/tool/globfiles/globfiles.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package globfiles

import (
"fmt"

"github.com/docker/docker-agent/pkg/tools/builtin"
"github.com/docker/docker-agent/pkg/tui/components/toolcommon"
"github.com/docker/docker-agent/pkg/tui/core/layout"
"github.com/docker/docker-agent/pkg/tui/service"
"github.com/docker/docker-agent/pkg/tui/types"
)

func New(msg *types.Message, sessionState service.SessionStateReader) layout.Model {
return toolcommon.NewBase(msg, sessionState, toolcommon.SimpleRendererWithResult(
extractArgs,
extractResult,
))
}

func extractArgs(args string) string {
parsed, err := toolcommon.ParseArgs[builtin.GlobFilesArgs](args)
if err != nil {
return ""
}

pattern := parsed.Pattern
if len(pattern) > 40 {
pattern = pattern[:37] + "..."
}

if parsed.Path != "" && parsed.Path != "." {
return fmt.Sprintf("%s in %s", pattern, toolcommon.ShortenPath(parsed.Path))
}
return pattern
}

func extractResult(msg *types.Message) string {
if msg.ToolResult == nil || msg.ToolResult.Meta == nil {
return "no matches"
}
meta, ok := msg.ToolResult.Meta.(builtin.GlobFilesMeta)
if !ok {
return "no matches"
}

if meta.FileCount == 0 {
return "no matches"
}

fileWord := "file"
if meta.FileCount != 1 {
fileWord = "files"
}

result := fmt.Sprintf("%d %s", meta.FileCount, fileWord)
if meta.Truncated {
result += " (truncated)"
}
return result
}
99 changes: 99 additions & 0 deletions pkg/tui/components/tool/globfiles/globfiles_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package globfiles

import (
"testing"

"github.com/docker/docker-agent/pkg/tools"
"github.com/docker/docker-agent/pkg/tools/builtin"
"github.com/docker/docker-agent/pkg/tui/types"
)

func TestExtractResult(t *testing.T) {
tests := []struct {
name string
meta *builtin.GlobFilesMeta
expected string
}{
{
name: "nil meta",
meta: nil,
expected: "no matches",
},
{
name: "zero files",
meta: &builtin.GlobFilesMeta{FileCount: 0},
expected: "no matches",
},
{
name: "single file",
meta: &builtin.GlobFilesMeta{FileCount: 1},
expected: "1 file",
},
{
name: "multiple files",
meta: &builtin.GlobFilesMeta{FileCount: 42},
expected: "42 files",
},
{
name: "truncated results",
meta: &builtin.GlobFilesMeta{FileCount: 100, Truncated: true},
expected: "100 files (truncated)",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
msg := &types.Message{}
if tt.meta != nil {
msg.ToolResult = &tools.ToolCallResult{Meta: *tt.meta}
}
result := extractResult(msg)
if result != tt.expected {
t.Errorf("extractResult() = %q, want %q", result, tt.expected)
}
})
}
}

func TestExtractArgs(t *testing.T) {
tests := []struct {
name string
args string
expected string
}{
{
name: "simple pattern",
args: `{"pattern": "**/*.go"}`,
expected: "**/*.go",
},
{
name: "pattern with path",
args: `{"pattern": "*.ts", "path": "src/components"}`,
expected: "*.ts in src/components",
},
{
name: "pattern with dot path",
args: `{"pattern": "**/*.json", "path": "."}`,
expected: "**/*.json",
},
{
name: "long pattern gets truncated",
args: `{"pattern": "src/very/deeply/nested/directory/structure/**/*.test.ts"}`,
expected: "src/very/deeply/nested/directory/stru...",
},
{
name: "invalid json",
args: `invalid`,
expected: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := extractArgs(tt.args)
if result != tt.expected {
t.Errorf("extractArgs(%q) = %q, want %q", tt.args, result, tt.expected)
}
})
}
}
Loading