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