forked from ghuntley/how-to-build-a-coding-agent
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcode_search_tool.go
More file actions
469 lines (392 loc) · 12.8 KB
/
code_search_tool.go
File metadata and controls
469 lines (392 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"os/exec"
"strings"
"github.com/anthropics/anthropic-sdk-go"
"github.com/invopop/jsonschema"
)
func main() {
verbose := flag.Bool("verbose", false, "enable verbose logging")
flag.Parse()
if *verbose {
log.SetOutput(os.Stderr)
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Println("Verbose logging enabled")
} else {
log.SetOutput(os.Stdout)
log.SetFlags(0)
log.SetPrefix("")
}
client := anthropic.NewClient()
if *verbose {
log.Println("Anthropic client initialized")
}
scanner := bufio.NewScanner(os.Stdin)
getUserMessage := func() (string, bool) {
if !scanner.Scan() {
return "", false
}
return scanner.Text(), true
}
tools := []ToolDefinition{ReadFileDefinition, ListFilesDefinition, BashDefinition, CodeSearchDefinition}
if *verbose {
log.Printf("Initialized %d tools", len(tools))
}
agent := NewAgent(&client, getUserMessage, tools, *verbose)
err := agent.Run(context.TODO())
if err != nil {
fmt.Printf("Error: %s\n", err.Error())
}
}
func NewAgent(
client *anthropic.Client,
getUserMessage func() (string, bool),
tools []ToolDefinition,
verbose bool,
) *Agent {
return &Agent{
client: client,
getUserMessage: getUserMessage,
tools: tools,
verbose: verbose,
}
}
type Agent struct {
client *anthropic.Client
getUserMessage func() (string, bool)
tools []ToolDefinition
verbose bool
}
func (a *Agent) Run(ctx context.Context) error {
conversation := []anthropic.MessageParam{}
if a.verbose {
log.Println("Starting chat session with tools enabled")
}
fmt.Println("Chat with Claude (use 'ctrl-c' to quit)")
for {
fmt.Print("\u001b[94mYou\u001b[0m: ")
userInput, ok := a.getUserMessage()
if !ok {
if a.verbose {
log.Println("User input ended, breaking from chat loop")
}
break
}
// Skip empty messages
if userInput == "" {
if a.verbose {
log.Println("Skipping empty message")
}
continue
}
if a.verbose {
log.Printf("User input received: %q", userInput)
}
userMessage := anthropic.NewUserMessage(anthropic.NewTextBlock(userInput))
conversation = append(conversation, userMessage)
if a.verbose {
log.Printf("Sending message to Claude, conversation length: %d", len(conversation))
}
message, err := a.runInference(ctx, conversation)
if err != nil {
if a.verbose {
log.Printf("Error during inference: %v", err)
}
return err
}
conversation = append(conversation, message.ToParam())
// Keep processing until Claude stops using tools
for {
// Collect all tool uses and their results
var toolResults []anthropic.ContentBlockParamUnion
var hasToolUse bool
if a.verbose {
log.Printf("Processing %d content blocks from Claude", len(message.Content))
}
for _, content := range message.Content {
switch content.Type {
case "text":
fmt.Printf("\u001b[93mClaude\u001b[0m: %s\n", content.Text)
case "tool_use":
hasToolUse = true
toolUse := content.AsToolUse()
if a.verbose {
log.Printf("Tool use detected: %s with input: %s", toolUse.Name, string(toolUse.Input))
}
fmt.Printf("\u001b[96mtool\u001b[0m: %s(%s)\n", toolUse.Name, string(toolUse.Input))
// Find and execute the tool
var toolResult string
var toolError error
var toolFound bool
for _, tool := range a.tools {
if tool.Name == toolUse.Name {
if a.verbose {
log.Printf("Executing tool: %s", tool.Name)
}
toolResult, toolError = tool.Function(toolUse.Input)
fmt.Printf("\u001b[92mresult\u001b[0m: %s\n", toolResult)
if toolError != nil {
fmt.Printf("\u001b[91merror\u001b[0m: %s\n", toolError.Error())
}
if a.verbose {
if toolError != nil {
log.Printf("Tool execution failed: %v", toolError)
} else {
log.Printf("Tool execution successful, result length: %d chars", len(toolResult))
}
}
toolFound = true
break
}
}
if !toolFound {
toolError = fmt.Errorf("tool '%s' not found", toolUse.Name)
fmt.Printf("\u001b[91merror\u001b[0m: %s\n", toolError.Error())
}
// Add tool result to collection
if toolError != nil {
toolResults = append(toolResults, anthropic.NewToolResultBlock(toolUse.ID, toolError.Error(), true))
} else {
toolResults = append(toolResults, anthropic.NewToolResultBlock(toolUse.ID, toolResult, false))
}
}
}
// If there were no tool uses, we're done
if !hasToolUse {
break
}
// Send all tool results back and get Claude's response
if a.verbose {
log.Printf("Sending %d tool results back to Claude", len(toolResults))
}
toolResultMessage := anthropic.NewUserMessage(toolResults...)
conversation = append(conversation, toolResultMessage)
// Get Claude's response after tool execution
message, err = a.runInference(ctx, conversation)
if err != nil {
if a.verbose {
log.Printf("Error during followup inference: %v", err)
}
return err
}
conversation = append(conversation, message.ToParam())
if a.verbose {
log.Printf("Received followup response with %d content blocks", len(message.Content))
}
// Continue loop to process the new message
}
}
if a.verbose {
log.Println("Chat session ended")
}
return nil
}
func (a *Agent) runInference(ctx context.Context, conversation []anthropic.MessageParam) (*anthropic.Message, error) {
anthropicTools := []anthropic.ToolUnionParam{}
for _, tool := range a.tools {
anthropicTools = append(anthropicTools, anthropic.ToolUnionParam{
OfTool: &anthropic.ToolParam{
Name: tool.Name,
Description: anthropic.String(tool.Description),
InputSchema: tool.InputSchema,
},
})
}
if a.verbose {
log.Printf("Making API call to Claude with model: %s and %d tools", anthropic.ModelClaude3_7SonnetLatest, len(anthropicTools))
}
message, err := a.client.Messages.New(ctx, anthropic.MessageNewParams{
Model: anthropic.ModelClaude3_7SonnetLatest,
MaxTokens: int64(1024),
Messages: conversation,
Tools: anthropicTools,
})
if a.verbose {
if err != nil {
log.Printf("API call failed: %v", err)
} else {
log.Printf("API call successful, response received")
}
}
return message, err
}
type ToolDefinition struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema anthropic.ToolInputSchemaParam `json:"input_schema"`
Function func(input json.RawMessage) (string, error)
}
var ReadFileDefinition = ToolDefinition{
Name: "read_file",
Description: "Read the contents of a given relative file path. Use this when you want to see what's inside a file. Do not use this with directory names.",
InputSchema: ReadFileInputSchema,
Function: ReadFile,
}
var ListFilesDefinition = ToolDefinition{
Name: "list_files",
Description: "List files and directories at a given path. If no path is provided, lists files in the current directory.",
InputSchema: ListFilesInputSchema,
Function: ListFiles,
}
var BashDefinition = ToolDefinition{
Name: "bash",
Description: "Execute a bash command and return its output. Use this to run shell commands.",
InputSchema: BashInputSchema,
Function: Bash,
}
var CodeSearchDefinition = ToolDefinition{
Name: "code_search",
Description: `Search for code patterns using ripgrep (rg).
Use this to find code patterns, function definitions, variable usage, or any text in the codebase.
You can search by pattern, file type, or directory.`,
InputSchema: CodeSearchInputSchema,
Function: CodeSearch,
}
type ReadFileInput struct {
Path string `json:"path" jsonschema_description:"The relative path of a file in the working directory."`
}
var ReadFileInputSchema = GenerateSchema[ReadFileInput]()
type ListFilesInput struct {
Path string `json:"path,omitempty" jsonschema_description:"Optional relative path to list files from. Defaults to current directory if not provided."`
}
var ListFilesInputSchema = GenerateSchema[ListFilesInput]()
type BashInput struct {
Command string `json:"command" jsonschema_description:"The bash command to execute."`
}
var BashInputSchema = GenerateSchema[BashInput]()
type CodeSearchInput struct {
Pattern string `json:"pattern" jsonschema_description:"The search pattern or regex to look for"`
Path string `json:"path,omitempty" jsonschema_description:"Optional path to search in (file or directory)"`
FileType string `json:"file_type,omitempty" jsonschema_description:"Optional file extension to limit search to (e.g., 'go', 'js', 'py')"`
CaseSensitive bool `json:"case_sensitive,omitempty" jsonschema_description:"Whether the search should be case sensitive (default: false)"`
}
var CodeSearchInputSchema = GenerateSchema[CodeSearchInput]()
func ReadFile(input json.RawMessage) (string, error) {
readFileInput := ReadFileInput{}
err := json.Unmarshal(input, &readFileInput)
if err != nil {
panic(err)
}
log.Printf("Reading file: %s", readFileInput.Path)
content, err := os.ReadFile(readFileInput.Path)
if err != nil {
log.Printf("Failed to read file %s: %v", readFileInput.Path, err)
return "", err
}
log.Printf("Successfully read file %s (%d bytes)", readFileInput.Path, len(content))
return string(content), nil
}
func ListFiles(input json.RawMessage) (string, error) {
listFilesInput := ListFilesInput{}
err := json.Unmarshal(input, &listFilesInput)
if err != nil {
panic(err)
}
dir := "."
if listFilesInput.Path != "" {
dir = listFilesInput.Path
}
log.Printf("Listing files in directory: %s", dir)
cmd := exec.Command("find", dir, "-type", "f", "-not", "-path", "*/.devenv/*", "-not", "-path", "*/.git/*")
output, err := cmd.Output()
if err != nil {
log.Printf("Failed to list files in %s: %v", dir, err)
return "", err
}
files := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(files) == 1 && files[0] == "" {
files = []string{}
}
result, err := json.Marshal(files)
if err != nil {
return "", err
}
log.Printf("Successfully listed %d files in %s", len(files), dir)
return string(result), nil
}
func Bash(input json.RawMessage) (string, error) {
bashInput := BashInput{}
err := json.Unmarshal(input, &bashInput)
if err != nil {
return "", err
}
log.Printf("Executing bash command: %s", bashInput.Command)
cmd := exec.Command("bash", "-c", bashInput.Command)
output, err := cmd.CombinedOutput()
if err != nil {
log.Printf("Bash command failed: %v", err)
return fmt.Sprintf("Command failed with error: %s\nOutput: %s", err.Error(), string(output)), nil
}
log.Printf("Bash command executed successfully, output length: %d chars", len(output))
return strings.TrimSpace(string(output)), nil
}
func CodeSearch(input json.RawMessage) (string, error) {
codeSearchInput := CodeSearchInput{}
err := json.Unmarshal(input, &codeSearchInput)
if err != nil {
return "", err
}
if codeSearchInput.Pattern == "" {
log.Printf("CodeSearch failed: pattern is required")
return "", fmt.Errorf("pattern is required")
}
log.Printf("Searching for pattern: %s", codeSearchInput.Pattern)
// Build ripgrep command
args := []string{"rg", "--line-number", "--with-filename", "--color=never"}
// Add case sensitivity flag
if !codeSearchInput.CaseSensitive {
args = append(args, "--ignore-case")
}
// Add file type filter if specified
if codeSearchInput.FileType != "" {
args = append(args, "--type", codeSearchInput.FileType)
}
// Add pattern
args = append(args, codeSearchInput.Pattern)
// Add path if specified
if codeSearchInput.Path != "" {
args = append(args, codeSearchInput.Path)
} else {
args = append(args, ".")
}
if a := false; a { // This is a hack to access verbose mode
log.Printf("Executing ripgrep with args: %v", args)
}
cmd := exec.Command(args[0], args[1:]...)
output, err := cmd.Output()
// ripgrep returns exit code 1 when no matches are found, which is not an error
if err != nil {
if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 1 {
log.Printf("No matches found for pattern: %s", codeSearchInput.Pattern)
return "No matches found", nil
}
log.Printf("Ripgrep command failed: %v", err)
return "", fmt.Errorf("search failed: %w", err)
}
result := strings.TrimSpace(string(output))
lines := strings.Split(result, "\n")
log.Printf("Found %d matches for pattern: %s", len(lines), codeSearchInput.Pattern)
// Limit output to prevent overwhelming responses
if len(lines) > 50 {
result = strings.Join(lines[:50], "\n") + fmt.Sprintf("\n... (showing first 50 of %d matches)", len(lines))
}
return result, nil
}
func GenerateSchema[T any]() anthropic.ToolInputSchemaParam {
reflector := jsonschema.Reflector{
AllowAdditionalProperties: false,
DoNotReference: true,
}
var v T
schema := reflector.Reflect(v)
return anthropic.ToolInputSchemaParam{
Properties: schema.Properties,
}
}