blob: 88354e3ca52a16fa85b607e0d23b99a6a79262e7 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package loop
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "log/slog"
8 "net/http"
9 "os"
10 "os/exec"
11 "runtime/debug"
12 "slices"
13 "strings"
14 "sync"
15 "time"
16
17 "sketch.dev/ant"
18 "sketch.dev/claudetool"
19)
20
21const (
22 userCancelMessage = "user requested agent to stop handling responses"
23)
24
25type CodingAgent interface {
26 // Init initializes an agent inside a docker container.
27 Init(AgentInit) error
28
29 // Ready returns a channel closed after Init successfully called.
30 Ready() <-chan struct{}
31
32 // URL reports the HTTP URL of this agent.
33 URL() string
34
35 // UserMessage enqueues a message to the agent and returns immediately.
36 UserMessage(ctx context.Context, msg string)
37
38 // WaitForMessage blocks until the agent has a response to give.
39 // Use AgentMessage.EndOfTurn to help determine if you want to
40 // drain the agent.
41 WaitForMessage(ctx context.Context) AgentMessage
42
43 // Loop begins the agent loop returns only when ctx is cancelled.
44 Loop(ctx context.Context)
45
46 CancelInnerLoop(cause error)
47
48 CancelToolUse(toolUseID string, cause error) error
49
50 // Returns a subset of the agent's message history.
51 Messages(start int, end int) []AgentMessage
52
53 // Returns the current number of messages in the history
54 MessageCount() int
55
56 TotalUsage() ant.CumulativeUsage
57 OriginalBudget() ant.Budget
58
59 // WaitForMessageCount returns when the agent has at more than clientMessageCount messages or the context is done.
60 WaitForMessageCount(ctx context.Context, greaterThan int)
61
62 WorkingDir() string
63
64 // Diff returns a unified diff of changes made since the agent was instantiated.
65 // If commit is non-nil, it shows the diff for just that specific commit.
66 Diff(commit *string) (string, error)
67
68 // InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
69 InitialCommit() string
70
71 // Title returns the current title of the conversation.
72 Title() string
73
74 // OS returns the operating system of the client.
75 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +000076
77 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
78 OutstandingLLMCallCount() int
79
80 // OutstandingToolCalls returns the names of outstanding tool calls.
81 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +000082 OutsideOS() string
83 OutsideHostname() string
84 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +000085 GitOrigin() string
Earl Lee2e463fb2025-04-17 11:22:22 -070086}
87
88type CodingAgentMessageType string
89
90const (
91 UserMessageType CodingAgentMessageType = "user"
92 AgentMessageType CodingAgentMessageType = "agent"
93 ErrorMessageType CodingAgentMessageType = "error"
94 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
95 ToolUseMessageType CodingAgentMessageType = "tool"
96 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
97 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
98
99 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
100)
101
102type AgentMessage struct {
103 Type CodingAgentMessageType `json:"type"`
104 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
105 EndOfTurn bool `json:"end_of_turn"`
106
107 Content string `json:"content"`
108 ToolName string `json:"tool_name,omitempty"`
109 ToolInput string `json:"input,omitempty"`
110 ToolResult string `json:"tool_result,omitempty"`
111 ToolError bool `json:"tool_error,omitempty"`
112 ToolCallId string `json:"tool_call_id,omitempty"`
113
114 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
115 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
116
Sean McCulloughd9f13372025-04-21 15:08:49 -0700117 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
118 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
119
Earl Lee2e463fb2025-04-17 11:22:22 -0700120 // Commits is a list of git commits for a commit message
121 Commits []*GitCommit `json:"commits,omitempty"`
122
123 Timestamp time.Time `json:"timestamp"`
124 ConversationID string `json:"conversation_id"`
125 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
126 Usage *ant.Usage `json:"usage,omitempty"`
127
128 // Message timing information
129 StartTime *time.Time `json:"start_time,omitempty"`
130 EndTime *time.Time `json:"end_time,omitempty"`
131 Elapsed *time.Duration `json:"elapsed,omitempty"`
132
133 // Turn duration - the time taken for a complete agent turn
134 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
135
136 Idx int `json:"idx"`
137}
138
139// GitCommit represents a single git commit for a commit message
140type GitCommit struct {
141 Hash string `json:"hash"` // Full commit hash
142 Subject string `json:"subject"` // Commit subject line
143 Body string `json:"body"` // Full commit message body
144 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
145}
146
147// ToolCall represents a single tool call within an agent message
148type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700149 Name string `json:"name"`
150 Input string `json:"input"`
151 ToolCallId string `json:"tool_call_id"`
152 ResultMessage *AgentMessage `json:"result_message,omitempty"`
153 Args string `json:"args,omitempty"`
154 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700155}
156
157func (a *AgentMessage) Attr() slog.Attr {
158 var attrs []any = []any{
159 slog.String("type", string(a.Type)),
160 }
161 if a.EndOfTurn {
162 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
163 }
164 if a.Content != "" {
165 attrs = append(attrs, slog.String("content", a.Content))
166 }
167 if a.ToolName != "" {
168 attrs = append(attrs, slog.String("tool_name", a.ToolName))
169 }
170 if a.ToolInput != "" {
171 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
172 }
173 if a.Elapsed != nil {
174 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
175 }
176 if a.TurnDuration != nil {
177 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
178 }
179 if a.ToolResult != "" {
180 attrs = append(attrs, slog.String("tool_result", a.ToolResult))
181 }
182 if a.ToolError {
183 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
184 }
185 if len(a.ToolCalls) > 0 {
186 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
187 for i, tc := range a.ToolCalls {
188 toolCallAttrs = append(toolCallAttrs, slog.Group(
189 fmt.Sprintf("tool_call_%d", i),
190 slog.String("name", tc.Name),
191 slog.String("input", tc.Input),
192 ))
193 }
194 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
195 }
196 if a.ConversationID != "" {
197 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
198 }
199 if a.ParentConversationID != nil {
200 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
201 }
202 if a.Usage != nil && !a.Usage.IsZero() {
203 attrs = append(attrs, a.Usage.Attr())
204 }
205 // TODO: timestamp, convo ids, idx?
206 return slog.Group("agent_message", attrs...)
207}
208
209func errorMessage(err error) AgentMessage {
210 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
211 if os.Getenv(("DEBUG")) == "1" {
212 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
213 }
214
215 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
216}
217
218func budgetMessage(err error) AgentMessage {
219 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
220}
221
222// ConvoInterface defines the interface for conversation interactions
223type ConvoInterface interface {
224 CumulativeUsage() ant.CumulativeUsage
225 ResetBudget(ant.Budget)
226 OverBudget() error
227 SendMessage(message ant.Message) (*ant.MessageResponse, error)
228 SendUserTextMessage(s string, otherContents ...ant.Content) (*ant.MessageResponse, error)
229 ToolResultContents(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error)
230 ToolResultCancelContents(resp *ant.MessageResponse) ([]ant.Content, error)
231 CancelToolUse(toolUseID string, cause error) error
232}
233
234type Agent struct {
235 convo ConvoInterface
236 config AgentConfig // config for this agent
237 workingDir string
238 repoRoot string // workingDir may be a subdir of repoRoot
239 url string
240 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
241 initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
242 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
243 ready chan struct{} // closed when the agent is initialized (only when under docker)
244 startedAt time.Time
245 originalBudget ant.Budget
246 title string
247 codereview *claudetool.CodeReviewer
Philip Zeyliger18532b22025-04-23 21:11:46 +0000248 // Outside information
249 outsideHostname string
250 outsideOS string
251 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000252 // URL of the git remote 'origin' if it exists
253 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700254
255 // Time when the current turn started (reset at the beginning of InnerLoop)
256 startOfTurn time.Time
257
258 // Inbox - for messages from the user to the agent.
259 // sent on by UserMessage
260 // . e.g. when user types into the chat textarea
261 // read from by GatherMessages
262 inbox chan string
263
264 // Outbox
265 // sent on by pushToOutbox
266 // via OnToolResult and OnResponse callbacks
267 // read from by WaitForMessage
268 // called by termui inside its repl loop.
269 outbox chan AgentMessage
270
271 // protects cancelInnerLoop
272 cancelInnerLoopMu sync.Mutex
273 // cancels potentially long-running tool_use calls or chains of them
274 cancelInnerLoop context.CancelCauseFunc
275
276 // protects following
277 mu sync.Mutex
278
279 // Stores all messages for this agent
280 history []AgentMessage
281
282 listeners []chan struct{}
283
284 // Track git commits we've already seen (by hash)
285 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000286
287 // Track outstanding LLM call IDs
288 outstandingLLMCalls map[string]struct{}
289
290 // Track outstanding tool calls by ID with their names
291 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700292}
293
294func (a *Agent) URL() string { return a.url }
295
296// Title returns the current title of the conversation.
297// If no title has been set, returns an empty string.
298func (a *Agent) Title() string {
299 a.mu.Lock()
300 defer a.mu.Unlock()
301 return a.title
302}
303
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000304// OutstandingLLMCallCount returns the number of outstanding LLM calls.
305func (a *Agent) OutstandingLLMCallCount() int {
306 a.mu.Lock()
307 defer a.mu.Unlock()
308 return len(a.outstandingLLMCalls)
309}
310
311// OutstandingToolCalls returns the names of outstanding tool calls.
312func (a *Agent) OutstandingToolCalls() []string {
313 a.mu.Lock()
314 defer a.mu.Unlock()
315
316 tools := make([]string, 0, len(a.outstandingToolCalls))
317 for _, toolName := range a.outstandingToolCalls {
318 tools = append(tools, toolName)
319 }
320 return tools
321}
322
Earl Lee2e463fb2025-04-17 11:22:22 -0700323// OS returns the operating system of the client.
324func (a *Agent) OS() string {
325 return a.config.ClientGOOS
326}
327
Philip Zeyliger18532b22025-04-23 21:11:46 +0000328// OutsideOS returns the operating system of the outside system.
329func (a *Agent) OutsideOS() string {
330 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000331}
332
Philip Zeyliger18532b22025-04-23 21:11:46 +0000333// OutsideHostname returns the hostname of the outside system.
334func (a *Agent) OutsideHostname() string {
335 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000336}
337
Philip Zeyliger18532b22025-04-23 21:11:46 +0000338// OutsideWorkingDir returns the working directory on the outside system.
339func (a *Agent) OutsideWorkingDir() string {
340 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000341}
342
343// GitOrigin returns the URL of the git remote 'origin' if it exists.
344func (a *Agent) GitOrigin() string {
345 return a.gitOrigin
346}
347
Earl Lee2e463fb2025-04-17 11:22:22 -0700348// SetTitle sets the title of the conversation.
349func (a *Agent) SetTitle(title string) {
350 a.mu.Lock()
351 defer a.mu.Unlock()
352 a.title = title
353 // Notify all listeners that the state has changed
354 for _, ch := range a.listeners {
355 close(ch)
356 }
357 a.listeners = a.listeners[:0]
358}
359
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000360// OnToolCall implements ant.Listener and tracks the start of a tool call.
361func (a *Agent) OnToolCall(ctx context.Context, convo *ant.Convo, id string, toolName string, toolInput json.RawMessage, content ant.Content) {
362 // Track the tool call
363 a.mu.Lock()
364 a.outstandingToolCalls[id] = toolName
365 a.mu.Unlock()
366}
367
Earl Lee2e463fb2025-04-17 11:22:22 -0700368// OnToolResult implements ant.Listener.
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000369func (a *Agent) OnToolResult(ctx context.Context, convo *ant.Convo, toolID string, toolName string, toolInput json.RawMessage, content ant.Content, result *string, err error) {
370 // Remove the tool call from outstanding calls
371 a.mu.Lock()
372 delete(a.outstandingToolCalls, toolID)
373 a.mu.Unlock()
374
Earl Lee2e463fb2025-04-17 11:22:22 -0700375 m := AgentMessage{
376 Type: ToolUseMessageType,
377 Content: content.Text,
378 ToolResult: content.ToolResult,
379 ToolError: content.ToolError,
380 ToolName: toolName,
381 ToolInput: string(toolInput),
382 ToolCallId: content.ToolUseID,
383 StartTime: content.StartTime,
384 EndTime: content.EndTime,
385 }
386
387 // Calculate the elapsed time if both start and end times are set
388 if content.StartTime != nil && content.EndTime != nil {
389 elapsed := content.EndTime.Sub(*content.StartTime)
390 m.Elapsed = &elapsed
391 }
392
393 m.ConversationID = convo.ID
394 if convo.Parent != nil {
395 m.ParentConversationID = &convo.Parent.ID
396 }
397 a.pushToOutbox(ctx, m)
398}
399
400// OnRequest implements ant.Listener.
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000401func (a *Agent) OnRequest(ctx context.Context, convo *ant.Convo, id string, msg *ant.Message) {
402 a.mu.Lock()
403 defer a.mu.Unlock()
404 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700405 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
406}
407
408// OnResponse implements ant.Listener. Responses contain messages from the LLM
409// that need to be displayed (as well as tool calls that we send along when
410// they're done). (It would be reasonable to also mention tool calls when they're
411// started, but we don't do that yet.)
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000412func (a *Agent) OnResponse(ctx context.Context, convo *ant.Convo, id string, resp *ant.MessageResponse) {
413 // Remove the LLM call from outstanding calls
414 a.mu.Lock()
415 delete(a.outstandingLLMCalls, id)
416 a.mu.Unlock()
417
Earl Lee2e463fb2025-04-17 11:22:22 -0700418 endOfTurn := false
419 if resp.StopReason != ant.StopReasonToolUse {
420 endOfTurn = true
421 }
422 m := AgentMessage{
423 Type: AgentMessageType,
424 Content: collectTextContent(resp),
425 EndOfTurn: endOfTurn,
426 Usage: &resp.Usage,
427 StartTime: resp.StartTime,
428 EndTime: resp.EndTime,
429 }
430
431 // Extract any tool calls from the response
432 if resp.StopReason == ant.StopReasonToolUse {
433 var toolCalls []ToolCall
434 for _, part := range resp.Content {
435 if part.Type == "tool_use" {
436 toolCalls = append(toolCalls, ToolCall{
437 Name: part.ToolName,
438 Input: string(part.ToolInput),
439 ToolCallId: part.ID,
440 })
441 }
442 }
443 m.ToolCalls = toolCalls
444 }
445
446 // Calculate the elapsed time if both start and end times are set
447 if resp.StartTime != nil && resp.EndTime != nil {
448 elapsed := resp.EndTime.Sub(*resp.StartTime)
449 m.Elapsed = &elapsed
450 }
451
452 m.ConversationID = convo.ID
453 if convo.Parent != nil {
454 m.ParentConversationID = &convo.Parent.ID
455 }
456 a.pushToOutbox(ctx, m)
457}
458
459// WorkingDir implements CodingAgent.
460func (a *Agent) WorkingDir() string {
461 return a.workingDir
462}
463
464// MessageCount implements CodingAgent.
465func (a *Agent) MessageCount() int {
466 a.mu.Lock()
467 defer a.mu.Unlock()
468 return len(a.history)
469}
470
471// Messages implements CodingAgent.
472func (a *Agent) Messages(start int, end int) []AgentMessage {
473 a.mu.Lock()
474 defer a.mu.Unlock()
475 return slices.Clone(a.history[start:end])
476}
477
478func (a *Agent) OriginalBudget() ant.Budget {
479 return a.originalBudget
480}
481
482// AgentConfig contains configuration for creating a new Agent.
483type AgentConfig struct {
484 Context context.Context
485 AntURL string
486 APIKey string
487 HTTPC *http.Client
488 Budget ant.Budget
489 GitUsername string
490 GitEmail string
491 SessionID string
492 ClientGOOS string
493 ClientGOARCH string
494 UseAnthropicEdit bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000495 // Outside information
496 OutsideHostname string
497 OutsideOS string
498 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700499}
500
501// NewAgent creates a new Agent.
502// It is not usable until Init() is called.
503func NewAgent(config AgentConfig) *Agent {
504 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000505 config: config,
506 ready: make(chan struct{}),
507 inbox: make(chan string, 100),
508 outbox: make(chan AgentMessage, 100),
509 startedAt: time.Now(),
510 originalBudget: config.Budget,
511 seenCommits: make(map[string]bool),
512 outsideHostname: config.OutsideHostname,
513 outsideOS: config.OutsideOS,
514 outsideWorkingDir: config.OutsideWorkingDir,
515 outstandingLLMCalls: make(map[string]struct{}),
516 outstandingToolCalls: make(map[string]string),
Earl Lee2e463fb2025-04-17 11:22:22 -0700517 }
518 return agent
519}
520
521type AgentInit struct {
522 WorkingDir string
523 NoGit bool // only for testing
524
525 InDocker bool
526 Commit string
527 GitRemoteAddr string
528 HostAddr string
529}
530
531func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700532 if a.convo != nil {
533 return fmt.Errorf("Agent.Init: already initialized")
534 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700535 ctx := a.config.Context
536 if ini.InDocker {
537 cmd := exec.CommandContext(ctx, "git", "stash")
538 cmd.Dir = ini.WorkingDir
539 if out, err := cmd.CombinedOutput(); err != nil {
540 return fmt.Errorf("git stash: %s: %v", out, err)
541 }
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700542 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
543 cmd.Dir = ini.WorkingDir
544 if out, err := cmd.CombinedOutput(); err != nil {
545 return fmt.Errorf("git remote add: %s: %v", out, err)
546 }
547 cmd = exec.CommandContext(ctx, "git", "fetch", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700548 cmd.Dir = ini.WorkingDir
549 if out, err := cmd.CombinedOutput(); err != nil {
550 return fmt.Errorf("git fetch: %s: %w", out, err)
551 }
552 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
553 cmd.Dir = ini.WorkingDir
554 if out, err := cmd.CombinedOutput(); err != nil {
555 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
556 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700557 a.lastHEAD = ini.Commit
558 a.gitRemoteAddr = ini.GitRemoteAddr
559 a.initialCommit = ini.Commit
560 if ini.HostAddr != "" {
561 a.url = "http://" + ini.HostAddr
562 }
563 }
564 a.workingDir = ini.WorkingDir
565
566 if !ini.NoGit {
567 repoRoot, err := repoRoot(ctx, a.workingDir)
568 if err != nil {
569 return fmt.Errorf("repoRoot: %w", err)
570 }
571 a.repoRoot = repoRoot
572
573 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
574 if err != nil {
575 return fmt.Errorf("resolveRef: %w", err)
576 }
577 a.initialCommit = commitHash
578
579 codereview, err := claudetool.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit)
580 if err != nil {
581 return fmt.Errorf("Agent.Init: claudetool.NewCodeReviewer: %w", err)
582 }
583 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000584
585 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700586 }
587 a.lastHEAD = a.initialCommit
588 a.convo = a.initConvo()
589 close(a.ready)
590 return nil
591}
592
593// initConvo initializes the conversation.
594// It must not be called until all agent fields are initialized,
595// particularly workingDir and git.
596func (a *Agent) initConvo() *ant.Convo {
597 ctx := a.config.Context
598 convo := ant.NewConvo(ctx, a.config.APIKey)
599 if a.config.HTTPC != nil {
600 convo.HTTPC = a.config.HTTPC
601 }
602 if a.config.AntURL != "" {
603 convo.URL = a.config.AntURL
604 }
605 convo.PromptCaching = true
606 convo.Budget = a.config.Budget
607
608 var editPrompt string
609 if a.config.UseAnthropicEdit {
610 editPrompt = "Then use the str_replace_editor tool to make those edits. For short complete file replacements, you may use the bash tool with cat and heredoc stdin."
611 } else {
612 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
613 }
614
615 convo.SystemPrompt = fmt.Sprintf(`
616You are an expert coding assistant and architect, with a specialty in Go.
617You are assisting the user to achieve their goals.
618
619Start by asking concise clarifying questions as needed.
620Once the intent is clear, work autonomously.
621
622Call the title tool early in the conversation to provide a brief summary of
623what the chat is about.
624
625Break down the overall goal into a series of smaller steps.
626(The first step is often: "Make a plan.")
627Then execute each step using tools.
628Update the plan if you have encountered problems or learned new information.
629
630When in doubt about a step, follow this broad workflow:
631
632- Think about how the current step fits into the overall plan.
633- Do research. Good tool choices: bash, think, keyword_search
634- Make edits.
635- Repeat.
636
637To make edits reliably and efficiently, first think about the intent of the edit,
638and what set of patches will achieve that intent.
639%s
640
641For renames or refactors, consider invoking gopls (via bash).
642
643The done tool provides a checklist of items you MUST verify and
644review before declaring that you are done. Before executing
645the done tool, run all the tools the done tool checklist asks
646for, including creating a git commit. Do not forget to run tests.
647
648<platform>
649%s/%s
650</platform>
651<pwd>
652%v
653</pwd>
654<git_root>
655%v
656</git_root>
Josh Bleecher Snyder833a0f82025-04-24 18:39:36 +0000657<HEAD>
658%v
659</HEAD>
660`, editPrompt, a.config.ClientGOOS, a.config.ClientGOARCH, a.workingDir, a.repoRoot, a.initialCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -0700661
662 // Register all tools with the conversation
663 // When adding, removing, or modifying tools here, double-check that the termui tool display
664 // template in termui/termui.go has pretty-printing support for all tools.
665 convo.Tools = []*ant.Tool{
666 claudetool.Bash, claudetool.Keyword,
667 claudetool.Think, a.titleTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
668 a.codereview.Tool(),
669 }
670 if a.config.UseAnthropicEdit {
671 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
672 } else {
673 convo.Tools = append(convo.Tools, claudetool.Patch)
674 }
675 convo.Listener = a
676 return convo
677}
678
679func (a *Agent) titleTool() *ant.Tool {
680 // titleTool creates the title tool that sets the conversation title.
681 title := &ant.Tool{
682 Name: "title",
683 Description: `Use this tool early in the conversation, BEFORE MAKING ANY GIT COMMITS, to summarize what the chat is about briefly.`,
684 InputSchema: json.RawMessage(`{
685 "type": "object",
686 "properties": {
687 "title": {
688 "type": "string",
689 "description": "A brief title summarizing what this chat is about"
690 }
691 },
692 "required": ["title"]
693}`),
694 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
695 var params struct {
696 Title string `json:"title"`
697 }
698 if err := json.Unmarshal(input, &params); err != nil {
699 return "", err
700 }
701 a.SetTitle(params.Title)
702 return fmt.Sprintf("Title set to: %s", params.Title), nil
703 },
704 }
705 return title
706}
707
708func (a *Agent) Ready() <-chan struct{} {
709 return a.ready
710}
711
712func (a *Agent) UserMessage(ctx context.Context, msg string) {
713 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
714 a.inbox <- msg
715}
716
717func (a *Agent) WaitForMessage(ctx context.Context) AgentMessage {
718 // TODO: Should this drain any outbox messages in case there are multiple?
719 select {
720 case msg := <-a.outbox:
721 return msg
722 case <-ctx.Done():
723 return errorMessage(ctx.Err())
724 }
725}
726
727func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
728 return a.convo.CancelToolUse(toolUseID, cause)
729}
730
731func (a *Agent) CancelInnerLoop(cause error) {
732 a.cancelInnerLoopMu.Lock()
733 defer a.cancelInnerLoopMu.Unlock()
734 if a.cancelInnerLoop != nil {
735 a.cancelInnerLoop(cause)
736 }
737}
738
739func (a *Agent) Loop(ctxOuter context.Context) {
740 for {
741 select {
742 case <-ctxOuter.Done():
743 return
744 default:
745 ctxInner, cancel := context.WithCancelCause(ctxOuter)
746 a.cancelInnerLoopMu.Lock()
747 // Set .cancelInnerLoop so the user can cancel whatever is happening
748 // inside InnerLoop(ctxInner) without canceling this outer Loop execution.
749 // This CancelInnerLoop func is intended be called from other goroutines,
750 // hence the mutex.
751 a.cancelInnerLoop = cancel
752 a.cancelInnerLoopMu.Unlock()
753 a.InnerLoop(ctxInner)
754 cancel(nil)
755 }
756 }
757}
758
759func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
760 if m.Timestamp.IsZero() {
761 m.Timestamp = time.Now()
762 }
763
764 // If this is an end-of-turn message, calculate the turn duration and add it to the message
765 if m.EndOfTurn && m.Type == AgentMessageType {
766 turnDuration := time.Since(a.startOfTurn)
767 m.TurnDuration = &turnDuration
768 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
769 }
770
771 slog.InfoContext(ctx, "agent message", m.Attr())
772
773 a.mu.Lock()
774 defer a.mu.Unlock()
775 m.Idx = len(a.history)
776 a.history = append(a.history, m)
777 a.outbox <- m
778
779 // Notify all listeners:
780 for _, ch := range a.listeners {
781 close(ch)
782 }
783 a.listeners = a.listeners[:0]
784}
785
786func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]ant.Content, error) {
787 var m []ant.Content
788 if block {
789 select {
790 case <-ctx.Done():
791 return m, ctx.Err()
792 case msg := <-a.inbox:
793 m = append(m, ant.Content{Type: "text", Text: msg})
794 }
795 }
796 for {
797 select {
798 case msg := <-a.inbox:
799 m = append(m, ant.Content{Type: "text", Text: msg})
800 default:
801 return m, nil
802 }
803 }
804}
805
806func (a *Agent) InnerLoop(ctx context.Context) {
807 // Reset the start of turn time
808 a.startOfTurn = time.Now()
809
810 // Wait for at least one message from the user.
811 msgs, err := a.GatherMessages(ctx, true)
812 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
813 return
814 }
815 // We do this as we go, but let's also do it at the end of the turn
816 defer func() {
817 if _, err := a.handleGitCommits(ctx); err != nil {
818 // Just log the error, don't stop execution
819 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
820 }
821 }()
822
823 userMessage := ant.Message{
824 Role: "user",
825 Content: msgs,
826 }
827 // convo.SendMessage does the actual network call to send this to anthropic. This blocks until the response is ready.
828 // TODO: pass ctx to SendMessage, and figure out how to square that ctx with convo's own .Ctx. Who owns the scope of this call?
829 resp, err := a.convo.SendMessage(userMessage)
830 if err != nil {
831 a.pushToOutbox(ctx, errorMessage(err))
832 return
833 }
834 for {
835 // TODO: here and below where we check the budget,
836 // we should review the UX: is it clear what happened?
837 // is it clear how to resume?
838 // should we let the user set a new budget?
839 if err := a.overBudget(ctx); err != nil {
840 return
841 }
842 if resp.StopReason != ant.StopReasonToolUse {
843 break
844 }
845 var results []ant.Content
846 cancelled := false
847 select {
848 case <-ctx.Done():
849 // Don't actually run any of the tools, but rather build a response
850 // for each tool_use message letting the LLM know that user canceled it.
851 results, err = a.convo.ToolResultCancelContents(resp)
852 if err != nil {
853 a.pushToOutbox(ctx, errorMessage(err))
854 }
855 cancelled = true
856 default:
857 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
858 // fall-through, when the user has not canceled the inner loop:
859 results, err = a.convo.ToolResultContents(ctx, resp)
860 if ctx.Err() != nil { // e.g. the user canceled the operation
861 cancelled = true
862 } else if err != nil {
863 a.pushToOutbox(ctx, errorMessage(err))
864 }
865 }
866
867 // Check for git commits. Currently we do this here, after we collect
868 // tool results, since that's when we know commits could have happened.
869 // We could instead do this when the turn ends, but I think it makes sense
870 // to do this as we go.
871 newCommits, err := a.handleGitCommits(ctx)
872 if err != nil {
873 // Just log the error, don't stop execution
874 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
875 }
876 var autoqualityMessages []string
877 if len(newCommits) == 1 {
878 formatted := a.codereview.Autoformat(ctx)
879 if len(formatted) > 0 {
880 msg := fmt.Sprintf(`
881I ran autoformatters and they updated these files:
882
883%s
884
885Please amend your latest git commit with these changes and then continue with what you were doing.`,
886 strings.Join(formatted, "\n"),
887 )[1:]
888 a.pushToOutbox(ctx, AgentMessage{
889 Type: AutoMessageType,
890 Content: msg,
891 Timestamp: time.Now(),
892 })
893 autoqualityMessages = append(autoqualityMessages, msg)
894 }
895 }
896
897 if err := a.overBudget(ctx); err != nil {
898 return
899 }
900
901 // Include, along with the tool results (which must go first for whatever reason),
902 // any messages that the user has sent along while the tool_use was executing concurrently.
903 msgs, err = a.GatherMessages(ctx, false)
904 if err != nil {
905 return
906 }
907 // Inject any auto-generated messages from quality checks.
908 for _, msg := range autoqualityMessages {
909 msgs = append(msgs, ant.Content{Type: "text", Text: msg})
910 }
911 if cancelled {
912 msgs = append(msgs, ant.Content{Type: "text", Text: cancelToolUseMessage})
913 // EndOfTurn is false here so that the client of this agent keeps processing
914 // messages from WaitForMessage() and gets the response from the LLM (usually
915 // something like "okay, I'll wait further instructions", but the user should
916 // be made aware of it regardless).
917 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
918 } else if err := a.convo.OverBudget(); err != nil {
919 budgetMsg := "We've exceeded our budget. Please ask the user to confirm before continuing by ending the turn."
920 msgs = append(msgs, ant.Content{Type: "text", Text: budgetMsg})
921 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
922 }
923 results = append(results, msgs...)
924 resp, err = a.convo.SendMessage(ant.Message{
925 Role: "user",
926 Content: results,
927 })
928 if err != nil {
929 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
930 break
931 }
932 if cancelled {
933 return
934 }
935 }
936}
937
938func (a *Agent) overBudget(ctx context.Context) error {
939 if err := a.convo.OverBudget(); err != nil {
940 m := budgetMessage(err)
941 m.Content = m.Content + "\n\nBudget reset."
942 a.pushToOutbox(ctx, budgetMessage(err))
943 a.convo.ResetBudget(a.originalBudget)
944 return err
945 }
946 return nil
947}
948
949func collectTextContent(msg *ant.MessageResponse) string {
950 // Collect all text content
951 var allText strings.Builder
952 for _, content := range msg.Content {
953 if content.Type == "text" && content.Text != "" {
954 if allText.Len() > 0 {
955 allText.WriteString("\n\n")
956 }
957 allText.WriteString(content.Text)
958 }
959 }
960 return allText.String()
961}
962
963func (a *Agent) TotalUsage() ant.CumulativeUsage {
964 a.mu.Lock()
965 defer a.mu.Unlock()
966 return a.convo.CumulativeUsage()
967}
968
969// WaitForMessageCount returns when the agent has at more than clientMessageCount messages or the context is done.
970func (a *Agent) WaitForMessageCount(ctx context.Context, greaterThan int) {
971 for a.MessageCount() <= greaterThan {
972 a.mu.Lock()
973 ch := make(chan struct{})
974 // Deletion happens when we notify.
975 a.listeners = append(a.listeners, ch)
976 a.mu.Unlock()
977
978 select {
979 case <-ctx.Done():
980 return
981 case <-ch:
982 continue
983 }
984 }
985}
986
987// Diff returns a unified diff of changes made since the agent was instantiated.
988func (a *Agent) Diff(commit *string) (string, error) {
989 if a.initialCommit == "" {
990 return "", fmt.Errorf("no initial commit reference available")
991 }
992
993 // Find the repository root
994 ctx := context.Background()
995
996 // If a specific commit hash is provided, show just that commit's changes
997 if commit != nil && *commit != "" {
998 // Validate that the commit looks like a valid git SHA
999 if !isValidGitSHA(*commit) {
1000 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1001 }
1002
1003 // Get the diff for just this commit
1004 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1005 cmd.Dir = a.repoRoot
1006 output, err := cmd.CombinedOutput()
1007 if err != nil {
1008 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1009 }
1010 return string(output), nil
1011 }
1012
1013 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1014 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1015 cmd.Dir = a.repoRoot
1016 output, err := cmd.CombinedOutput()
1017 if err != nil {
1018 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1019 }
1020
1021 return string(output), nil
1022}
1023
1024// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1025func (a *Agent) InitialCommit() string {
1026 return a.initialCommit
1027}
1028
1029// handleGitCommits() highlights new commits to the user. When running
1030// under docker, new HEADs are pushed to a branch according to the title.
1031func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1032 if a.repoRoot == "" {
1033 return nil, nil
1034 }
1035
1036 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1037 if err != nil {
1038 return nil, err
1039 }
1040 if head == a.lastHEAD {
1041 return nil, nil // nothing to do
1042 }
1043 defer func() {
1044 a.lastHEAD = head
1045 }()
1046
1047 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1048 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1049 // to the last 100 commits.
1050 var commits []*GitCommit
1051
1052 // Get commits since the initial commit
1053 // Format: <hash>\0<subject>\0<body>\0
1054 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1055 // Limit to 100 commits to avoid overwhelming the user
1056 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1057 cmd.Dir = a.repoRoot
1058 output, err := cmd.Output()
1059 if err != nil {
1060 return nil, fmt.Errorf("failed to get git log: %w", err)
1061 }
1062
1063 // Parse git log output and filter out already seen commits
1064 parsedCommits := parseGitLog(string(output))
1065
1066 var headCommit *GitCommit
1067
1068 // Filter out commits we've already seen
1069 for _, commit := range parsedCommits {
1070 if commit.Hash == head {
1071 headCommit = &commit
1072 }
1073
1074 // Skip if we've seen this commit before. If our head has changed, always include that.
1075 if a.seenCommits[commit.Hash] && commit.Hash != head {
1076 continue
1077 }
1078
1079 // Mark this commit as seen
1080 a.seenCommits[commit.Hash] = true
1081
1082 // Add to our list of new commits
1083 commits = append(commits, &commit)
1084 }
1085
1086 if a.gitRemoteAddr != "" {
1087 if headCommit == nil {
1088 // I think this can only happen if we have a bug or if there's a race.
1089 headCommit = &GitCommit{}
1090 headCommit.Hash = head
1091 headCommit.Subject = "unknown"
1092 commits = append(commits, headCommit)
1093 }
1094
1095 cleanTitle := titleToBranch(a.title)
1096 if cleanTitle == "" {
1097 cleanTitle = a.config.SessionID
1098 }
1099 branch := "sketch/" + cleanTitle
1100
1101 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1102 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1103 // then use push with lease to replace.
1104 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1105 cmd.Dir = a.workingDir
1106 if out, err := cmd.CombinedOutput(); err != nil {
1107 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1108 } else {
1109 headCommit.PushedBranch = branch
1110 }
1111 }
1112
1113 // If we found new commits, create a message
1114 if len(commits) > 0 {
1115 msg := AgentMessage{
1116 Type: CommitMessageType,
1117 Timestamp: time.Now(),
1118 Commits: commits,
1119 }
1120 a.pushToOutbox(ctx, msg)
1121 }
1122 return commits, nil
1123}
1124
1125func titleToBranch(s string) string {
1126 // Convert to lowercase
1127 s = strings.ToLower(s)
1128
1129 // Replace spaces with hyphens
1130 s = strings.ReplaceAll(s, " ", "-")
1131
1132 // Remove any character that isn't a-z or hyphen
1133 var result strings.Builder
1134 for _, r := range s {
1135 if (r >= 'a' && r <= 'z') || r == '-' {
1136 result.WriteRune(r)
1137 }
1138 }
1139 return result.String()
1140}
1141
1142// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1143// and returns an array of GitCommit structs.
1144func parseGitLog(output string) []GitCommit {
1145 var commits []GitCommit
1146
1147 // No output means no commits
1148 if len(output) == 0 {
1149 return commits
1150 }
1151
1152 // Split by NULL byte
1153 parts := strings.Split(output, "\x00")
1154
1155 // Process in triplets (hash, subject, body)
1156 for i := 0; i < len(parts); i++ {
1157 // Skip empty parts
1158 if parts[i] == "" {
1159 continue
1160 }
1161
1162 // This should be a hash
1163 hash := strings.TrimSpace(parts[i])
1164
1165 // Make sure we have at least a subject part available
1166 if i+1 >= len(parts) {
1167 break // No more parts available
1168 }
1169
1170 // Get the subject
1171 subject := strings.TrimSpace(parts[i+1])
1172
1173 // Get the body if available
1174 body := ""
1175 if i+2 < len(parts) {
1176 body = strings.TrimSpace(parts[i+2])
1177 }
1178
1179 // Skip to the next triplet
1180 i += 2
1181
1182 commits = append(commits, GitCommit{
1183 Hash: hash,
1184 Subject: subject,
1185 Body: body,
1186 })
1187 }
1188
1189 return commits
1190}
1191
1192func repoRoot(ctx context.Context, dir string) (string, error) {
1193 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1194 stderr := new(strings.Builder)
1195 cmd.Stderr = stderr
1196 cmd.Dir = dir
1197 out, err := cmd.Output()
1198 if err != nil {
1199 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1200 }
1201 return strings.TrimSpace(string(out)), nil
1202}
1203
1204func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1205 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1206 stderr := new(strings.Builder)
1207 cmd.Stderr = stderr
1208 cmd.Dir = dir
1209 out, err := cmd.Output()
1210 if err != nil {
1211 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1212 }
1213 // TODO: validate that out is valid hex
1214 return strings.TrimSpace(string(out)), nil
1215}
1216
1217// isValidGitSHA validates if a string looks like a valid git SHA hash.
1218// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1219func isValidGitSHA(sha string) bool {
1220 // Git SHA must be a hexadecimal string with at least 4 characters
1221 if len(sha) < 4 || len(sha) > 40 {
1222 return false
1223 }
1224
1225 // Check if the string only contains hexadecimal characters
1226 for _, char := range sha {
1227 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1228 return false
1229 }
1230 }
1231
1232 return true
1233}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001234
1235// getGitOrigin returns the URL of the git remote 'origin' if it exists
1236func getGitOrigin(ctx context.Context, dir string) string {
1237 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1238 cmd.Dir = dir
1239 stderr := new(strings.Builder)
1240 cmd.Stderr = stderr
1241 out, err := cmd.Output()
1242 if err != nil {
1243 return ""
1244 }
1245 return strings.TrimSpace(string(out))
1246}