1
0
Fork 0
mirror of https://github.com/imjasonh/terraform-playground synced 2026-07-17 14:44:33 +00:00
terraform-playground/driftlessaf/internal/cifixer/tools.go
Jason Hall 9f63f1f524 add ci-fixer
Signed-off-by: Jason Hall <jason@chainguard.dev>
2026-02-04 11:38:44 -05:00

283 lines
8.2 KiB
Go

package cifixer
import (
"context"
"encoding/json"
"chainguard.dev/driftlessaf/agents/evals"
"chainguard.dev/driftlessaf/agents/executor/claudeexecutor"
"chainguard.dev/driftlessaf/agents/toolcall/claudetool"
"github.com/anthropics/anthropic-sdk-go"
)
// CreateTools creates the tool definitions for the CI fixer agent.
func CreateTools(fs FileSystem) map[string]claudeexecutor.ToolMetadata[*CIFixResult] {
return map[string]claudeexecutor.ToolMetadata[*CIFixResult]{
"read_file": readFileTool(fs),
"write_file": writeFileTool(fs),
"list_files": listFilesTool(fs),
"search_files": searchFilesTool(fs),
}
}
func readFileTool(fs FileSystem) claudeexecutor.ToolMetadata[*CIFixResult] {
return claudeexecutor.ToolMetadata[*CIFixResult]{
Definition: anthropic.ToolParam{
Name: "read_file",
Description: anthropic.String("Read the contents of a file in the repository. Use this to examine source code, configuration files, or any other text file."),
InputSchema: anthropic.ToolInputSchemaParam{
Properties: map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the file relative to repo root",
},
},
Required: []string{"path"},
},
},
Handler: func(ctx context.Context, toolUse anthropic.ToolUseBlock, trace *evals.Trace[*CIFixResult], result **CIFixResult) map[string]any {
params, errResp := claudetool.NewParams(toolUse)
if errResp != nil {
return errResp
}
path, errResp := claudetool.Param[string](params, "path")
if errResp != nil {
return errResp
}
content, err := fs.ReadFile(path)
if err != nil {
return claudetool.Error("failed to read file %q: %v", path, err)
}
return map[string]any{
"content": content,
}
},
}
}
func writeFileTool(fs FileSystem) claudeexecutor.ToolMetadata[*CIFixResult] {
return claudeexecutor.ToolMetadata[*CIFixResult]{
Definition: anthropic.ToolParam{
Name: "write_file",
Description: anthropic.String("Write content to a file in the repository. This will overwrite existing files or create new ones. Use this to apply fixes to source code."),
InputSchema: anthropic.ToolInputSchemaParam{
Properties: map[string]any{
"path": map[string]any{
"type": "string",
"description": "Path to the file relative to repo root",
},
"content": map[string]any{
"type": "string",
"description": "New file content",
},
},
Required: []string{"path", "content"},
},
},
Handler: func(ctx context.Context, toolUse anthropic.ToolUseBlock, trace *evals.Trace[*CIFixResult], result **CIFixResult) map[string]any {
params, errResp := claudetool.NewParams(toolUse)
if errResp != nil {
return errResp
}
path, errResp := claudetool.Param[string](params, "path")
if errResp != nil {
return errResp
}
content, errResp := claudetool.Param[string](params, "content")
if errResp != nil {
return errResp
}
if err := fs.WriteFile(path, content); err != nil {
return claudetool.Error("failed to write file %q: %v", path, err)
}
return map[string]any{
"success": true,
"path": path,
}
},
}
}
func listFilesTool(fs FileSystem) claudeexecutor.ToolMetadata[*CIFixResult] {
return claudeexecutor.ToolMetadata[*CIFixResult]{
Definition: anthropic.ToolParam{
Name: "list_files",
Description: anthropic.String("List files in a directory. Optionally filter by glob pattern (e.g., '*.go', '*.ts'). Use this to explore the repository structure."),
InputSchema: anthropic.ToolInputSchemaParam{
Properties: map[string]any{
"path": map[string]any{
"type": "string",
"description": "Directory path relative to repo root (empty for root)",
},
"pattern": map[string]any{
"type": "string",
"description": "Glob pattern to filter files (e.g., '*.go')",
},
},
},
},
Handler: func(ctx context.Context, toolUse anthropic.ToolUseBlock, trace *evals.Trace[*CIFixResult], result **CIFixResult) map[string]any {
params, errResp := claudetool.NewParams(toolUse)
if errResp != nil {
return errResp
}
path, _ := claudetool.OptionalParam[string](params, "path", "")
pattern, _ := claudetool.OptionalParam[string](params, "pattern", "")
files, err := fs.ListFiles(path, pattern)
if err != nil {
return claudetool.Error("failed to list files: %v", err)
}
return map[string]any{
"files": files,
"count": len(files),
}
},
}
}
func searchFilesTool(fs FileSystem) claudeexecutor.ToolMetadata[*CIFixResult] {
return claudeexecutor.ToolMetadata[*CIFixResult]{
Definition: anthropic.ToolParam{
Name: "search_files",
Description: anthropic.String("Search for a pattern in files using regex. Returns matching lines with file paths and line numbers. Use this to find where specific code or patterns are used."),
InputSchema: anthropic.ToolInputSchemaParam{
Properties: map[string]any{
"pattern": map[string]any{
"type": "string",
"description": "Search pattern (regex)",
},
"path": map[string]any{
"type": "string",
"description": "Directory to search in (empty for root)",
},
},
Required: []string{"pattern"},
},
},
Handler: func(ctx context.Context, toolUse anthropic.ToolUseBlock, trace *evals.Trace[*CIFixResult], result **CIFixResult) map[string]any {
params, errResp := claudetool.NewParams(toolUse)
if errResp != nil {
return errResp
}
pattern, errResp := claudetool.Param[string](params, "pattern")
if errResp != nil {
return errResp
}
path, _ := claudetool.OptionalParam[string](params, "path", "")
matches, err := fs.SearchFiles(pattern, path)
if err != nil {
return claudetool.Error("search failed: %v", err)
}
return map[string]any{
"matches": matches,
"count": len(matches),
}
},
}
}
// CreateMockTools creates tools that use a MockFileSystem for testing.
func CreateMockTools(files map[string]string) (map[string]claudeexecutor.ToolMetadata[*CIFixResult], *MockFileSystem) {
mockFS := NewMockFileSystem(files)
return CreateTools(mockFS), mockFS
}
// MockFileSystem implements FileSystem for testing.
type MockFileSystem struct {
files map[string]string
changedFiles map[string]bool
}
// NewMockFileSystem creates a new MockFileSystem with the given initial files.
func NewMockFileSystem(files map[string]string) *MockFileSystem {
// Make a copy to avoid mutation
filesCopy := make(map[string]string, len(files))
for k, v := range files {
filesCopy[k] = v
}
return &MockFileSystem{
files: filesCopy,
changedFiles: make(map[string]bool),
}
}
func (m *MockFileSystem) ReadFile(path string) (string, error) {
content, ok := m.files[path]
if !ok {
return "", &fileNotFoundError{path: path}
}
return content, nil
}
func (m *MockFileSystem) WriteFile(path, content string) error {
m.files[path] = content
m.changedFiles[path] = true
return nil
}
func (m *MockFileSystem) ListFiles(path, pattern string) ([]string, error) {
var files []string
for f := range m.files {
files = append(files, f)
}
return files, nil
}
func (m *MockFileSystem) SearchFiles(pattern, path string) ([]SearchMatch, error) {
// Simplified search for testing
return nil, nil
}
func (m *MockFileSystem) RunCommand(command string, args []string) (string, error) {
return "", &commandNotAllowedError{command: command}
}
func (m *MockFileSystem) GetChangedFiles() []string {
files := make([]string, 0, len(m.changedFiles))
for f := range m.changedFiles {
files = append(files, f)
}
return files
}
// GetFileContent returns the current content of a file (for test assertions).
func (m *MockFileSystem) GetFileContent(path string) (string, bool) {
content, ok := m.files[path]
return content, ok
}
type fileNotFoundError struct {
path string
}
func (e *fileNotFoundError) Error() string {
return "file not found: " + e.path
}
type commandNotAllowedError struct {
command string
}
func (e *commandNotAllowedError) Error() string {
return "command not allowed: " + e.command
}
// ToolResultToJSON converts a tool result map to JSON for logging/debugging.
func ToolResultToJSON(result map[string]any) string {
b, _ := json.MarshalIndent(result, "", " ")
return string(b)
}