blob: 55f92b571be1b6df940dcf91a1ea206e73c59546 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package loop
2
3import (
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07004 "cmp"
Earl Lee2e463fb2025-04-17 11:22:22 -07005 "context"
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07006 _ "embed"
Earl Lee2e463fb2025-04-17 11:22:22 -07007 "encoding/json"
8 "fmt"
9 "log/slog"
10 "net/http"
11 "os"
12 "os/exec"
13 "runtime/debug"
14 "slices"
15 "strings"
16 "sync"
17 "time"
18
19 "sketch.dev/ant"
20 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000021 "sketch.dev/claudetool/bashkit"
Earl Lee2e463fb2025-04-17 11:22:22 -070022)
23
24const (
25 userCancelMessage = "user requested agent to stop handling responses"
26)
27
28type CodingAgent interface {
29 // Init initializes an agent inside a docker container.
30 Init(AgentInit) error
31
32 // Ready returns a channel closed after Init successfully called.
33 Ready() <-chan struct{}
34
35 // URL reports the HTTP URL of this agent.
36 URL() string
37
38 // UserMessage enqueues a message to the agent and returns immediately.
39 UserMessage(ctx context.Context, msg string)
40
41 // WaitForMessage blocks until the agent has a response to give.
42 // Use AgentMessage.EndOfTurn to help determine if you want to
43 // drain the agent.
44 WaitForMessage(ctx context.Context) AgentMessage
45
46 // Loop begins the agent loop returns only when ctx is cancelled.
47 Loop(ctx context.Context)
48
Sean McCulloughedc88dc2025-04-30 02:55:01 +000049 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070050
51 CancelToolUse(toolUseID string, cause error) error
52
53 // Returns a subset of the agent's message history.
54 Messages(start int, end int) []AgentMessage
55
56 // Returns the current number of messages in the history
57 MessageCount() int
58
59 TotalUsage() ant.CumulativeUsage
60 OriginalBudget() ant.Budget
61
62 // WaitForMessageCount returns when the agent has at more than clientMessageCount messages or the context is done.
63 WaitForMessageCount(ctx context.Context, greaterThan int)
64
65 WorkingDir() string
66
67 // Diff returns a unified diff of changes made since the agent was instantiated.
68 // If commit is non-nil, it shows the diff for just that specific commit.
69 Diff(commit *string) (string, error)
70
71 // InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
72 InitialCommit() string
73
74 // Title returns the current title of the conversation.
75 Title() string
76
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000077 // BranchName returns the git branch name for the conversation.
78 BranchName() string
79
Earl Lee2e463fb2025-04-17 11:22:22 -070080 // OS returns the operating system of the client.
81 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +000082
Philip Zeyligerc72fff52025-04-29 20:17:54 +000083 // SessionID returns the unique session identifier.
84 SessionID() string
85
Philip Zeyliger99a9a022025-04-27 15:15:25 +000086 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
87 OutstandingLLMCallCount() int
88
89 // OutstandingToolCalls returns the names of outstanding tool calls.
90 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +000091 OutsideOS() string
92 OutsideHostname() string
93 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +000094 GitOrigin() string
Earl Lee2e463fb2025-04-17 11:22:22 -070095}
96
97type CodingAgentMessageType string
98
99const (
100 UserMessageType CodingAgentMessageType = "user"
101 AgentMessageType CodingAgentMessageType = "agent"
102 ErrorMessageType CodingAgentMessageType = "error"
103 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
104 ToolUseMessageType CodingAgentMessageType = "tool"
105 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
106 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
107
108 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
109)
110
111type AgentMessage struct {
112 Type CodingAgentMessageType `json:"type"`
113 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
114 EndOfTurn bool `json:"end_of_turn"`
115
116 Content string `json:"content"`
117 ToolName string `json:"tool_name,omitempty"`
118 ToolInput string `json:"input,omitempty"`
119 ToolResult string `json:"tool_result,omitempty"`
120 ToolError bool `json:"tool_error,omitempty"`
121 ToolCallId string `json:"tool_call_id,omitempty"`
122
123 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
124 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
125
Sean McCulloughd9f13372025-04-21 15:08:49 -0700126 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
127 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
128
Earl Lee2e463fb2025-04-17 11:22:22 -0700129 // Commits is a list of git commits for a commit message
130 Commits []*GitCommit `json:"commits,omitempty"`
131
132 Timestamp time.Time `json:"timestamp"`
133 ConversationID string `json:"conversation_id"`
134 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
135 Usage *ant.Usage `json:"usage,omitempty"`
136
137 // Message timing information
138 StartTime *time.Time `json:"start_time,omitempty"`
139 EndTime *time.Time `json:"end_time,omitempty"`
140 Elapsed *time.Duration `json:"elapsed,omitempty"`
141
142 // Turn duration - the time taken for a complete agent turn
143 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
144
145 Idx int `json:"idx"`
146}
147
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700148// SetConvo sets m.ConversationID and m.ParentConversationID based on convo.
149func (m *AgentMessage) SetConvo(convo *ant.Convo) {
150 if convo == nil {
151 m.ConversationID = ""
152 m.ParentConversationID = nil
153 return
154 }
155 m.ConversationID = convo.ID
156 if convo.Parent != nil {
157 m.ParentConversationID = &convo.Parent.ID
158 }
159}
160
Earl Lee2e463fb2025-04-17 11:22:22 -0700161// GitCommit represents a single git commit for a commit message
162type GitCommit struct {
163 Hash string `json:"hash"` // Full commit hash
164 Subject string `json:"subject"` // Commit subject line
165 Body string `json:"body"` // Full commit message body
166 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
167}
168
169// ToolCall represents a single tool call within an agent message
170type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700171 Name string `json:"name"`
172 Input string `json:"input"`
173 ToolCallId string `json:"tool_call_id"`
174 ResultMessage *AgentMessage `json:"result_message,omitempty"`
175 Args string `json:"args,omitempty"`
176 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700177}
178
179func (a *AgentMessage) Attr() slog.Attr {
180 var attrs []any = []any{
181 slog.String("type", string(a.Type)),
182 }
183 if a.EndOfTurn {
184 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
185 }
186 if a.Content != "" {
187 attrs = append(attrs, slog.String("content", a.Content))
188 }
189 if a.ToolName != "" {
190 attrs = append(attrs, slog.String("tool_name", a.ToolName))
191 }
192 if a.ToolInput != "" {
193 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
194 }
195 if a.Elapsed != nil {
196 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
197 }
198 if a.TurnDuration != nil {
199 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
200 }
201 if a.ToolResult != "" {
202 attrs = append(attrs, slog.String("tool_result", a.ToolResult))
203 }
204 if a.ToolError {
205 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
206 }
207 if len(a.ToolCalls) > 0 {
208 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
209 for i, tc := range a.ToolCalls {
210 toolCallAttrs = append(toolCallAttrs, slog.Group(
211 fmt.Sprintf("tool_call_%d", i),
212 slog.String("name", tc.Name),
213 slog.String("input", tc.Input),
214 ))
215 }
216 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
217 }
218 if a.ConversationID != "" {
219 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
220 }
221 if a.ParentConversationID != nil {
222 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
223 }
224 if a.Usage != nil && !a.Usage.IsZero() {
225 attrs = append(attrs, a.Usage.Attr())
226 }
227 // TODO: timestamp, convo ids, idx?
228 return slog.Group("agent_message", attrs...)
229}
230
231func errorMessage(err error) AgentMessage {
232 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
233 if os.Getenv(("DEBUG")) == "1" {
234 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
235 }
236
237 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
238}
239
240func budgetMessage(err error) AgentMessage {
241 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
242}
243
244// ConvoInterface defines the interface for conversation interactions
245type ConvoInterface interface {
246 CumulativeUsage() ant.CumulativeUsage
247 ResetBudget(ant.Budget)
248 OverBudget() error
249 SendMessage(message ant.Message) (*ant.MessageResponse, error)
250 SendUserTextMessage(s string, otherContents ...ant.Content) (*ant.MessageResponse, error)
251 ToolResultContents(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error)
252 ToolResultCancelContents(resp *ant.MessageResponse) ([]ant.Content, error)
253 CancelToolUse(toolUseID string, cause error) error
254}
255
256type Agent struct {
257 convo ConvoInterface
258 config AgentConfig // config for this agent
259 workingDir string
260 repoRoot string // workingDir may be a subdir of repoRoot
261 url string
262 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
263 initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
264 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
265 ready chan struct{} // closed when the agent is initialized (only when under docker)
266 startedAt time.Time
267 originalBudget ant.Budget
268 title string
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700269 branchName string
Earl Lee2e463fb2025-04-17 11:22:22 -0700270 codereview *claudetool.CodeReviewer
Philip Zeyliger18532b22025-04-23 21:11:46 +0000271 // Outside information
272 outsideHostname string
273 outsideOS string
274 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000275 // URL of the git remote 'origin' if it exists
276 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700277
278 // Time when the current turn started (reset at the beginning of InnerLoop)
279 startOfTurn time.Time
280
281 // Inbox - for messages from the user to the agent.
282 // sent on by UserMessage
283 // . e.g. when user types into the chat textarea
284 // read from by GatherMessages
285 inbox chan string
286
287 // Outbox
288 // sent on by pushToOutbox
289 // via OnToolResult and OnResponse callbacks
290 // read from by WaitForMessage
291 // called by termui inside its repl loop.
292 outbox chan AgentMessage
293
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000294 // protects cancelTurn
295 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700296 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000297 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700298
299 // protects following
300 mu sync.Mutex
301
302 // Stores all messages for this agent
303 history []AgentMessage
304
305 listeners []chan struct{}
306
307 // Track git commits we've already seen (by hash)
308 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000309
310 // Track outstanding LLM call IDs
311 outstandingLLMCalls map[string]struct{}
312
313 // Track outstanding tool calls by ID with their names
314 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700315}
316
317func (a *Agent) URL() string { return a.url }
318
319// Title returns the current title of the conversation.
320// If no title has been set, returns an empty string.
321func (a *Agent) Title() string {
322 a.mu.Lock()
323 defer a.mu.Unlock()
324 return a.title
325}
326
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000327// BranchName returns the git branch name for the conversation.
328func (a *Agent) BranchName() string {
329 a.mu.Lock()
330 defer a.mu.Unlock()
331 return a.branchName
332}
333
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000334// OutstandingLLMCallCount returns the number of outstanding LLM calls.
335func (a *Agent) OutstandingLLMCallCount() int {
336 a.mu.Lock()
337 defer a.mu.Unlock()
338 return len(a.outstandingLLMCalls)
339}
340
341// OutstandingToolCalls returns the names of outstanding tool calls.
342func (a *Agent) OutstandingToolCalls() []string {
343 a.mu.Lock()
344 defer a.mu.Unlock()
345
346 tools := make([]string, 0, len(a.outstandingToolCalls))
347 for _, toolName := range a.outstandingToolCalls {
348 tools = append(tools, toolName)
349 }
350 return tools
351}
352
Earl Lee2e463fb2025-04-17 11:22:22 -0700353// OS returns the operating system of the client.
354func (a *Agent) OS() string {
355 return a.config.ClientGOOS
356}
357
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000358func (a *Agent) SessionID() string {
359 return a.config.SessionID
360}
361
Philip Zeyliger18532b22025-04-23 21:11:46 +0000362// OutsideOS returns the operating system of the outside system.
363func (a *Agent) OutsideOS() string {
364 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000365}
366
Philip Zeyliger18532b22025-04-23 21:11:46 +0000367// OutsideHostname returns the hostname of the outside system.
368func (a *Agent) OutsideHostname() string {
369 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000370}
371
Philip Zeyliger18532b22025-04-23 21:11:46 +0000372// OutsideWorkingDir returns the working directory on the outside system.
373func (a *Agent) OutsideWorkingDir() string {
374 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000375}
376
377// GitOrigin returns the URL of the git remote 'origin' if it exists.
378func (a *Agent) GitOrigin() string {
379 return a.gitOrigin
380}
381
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700382// SetTitleBranch sets the title and branch name of the conversation.
383func (a *Agent) SetTitleBranch(title, branchName string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700384 a.mu.Lock()
385 defer a.mu.Unlock()
386 a.title = title
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700387 a.branchName = branchName
Earl Lee2e463fb2025-04-17 11:22:22 -0700388 // Notify all listeners that the state has changed
389 for _, ch := range a.listeners {
390 close(ch)
391 }
392 a.listeners = a.listeners[:0]
393}
394
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000395// OnToolCall implements ant.Listener and tracks the start of a tool call.
396func (a *Agent) OnToolCall(ctx context.Context, convo *ant.Convo, id string, toolName string, toolInput json.RawMessage, content ant.Content) {
397 // Track the tool call
398 a.mu.Lock()
399 a.outstandingToolCalls[id] = toolName
400 a.mu.Unlock()
401}
402
Earl Lee2e463fb2025-04-17 11:22:22 -0700403// OnToolResult implements ant.Listener.
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000404func (a *Agent) OnToolResult(ctx context.Context, convo *ant.Convo, toolID string, toolName string, toolInput json.RawMessage, content ant.Content, result *string, err error) {
405 // Remove the tool call from outstanding calls
406 a.mu.Lock()
407 delete(a.outstandingToolCalls, toolID)
408 a.mu.Unlock()
409
Earl Lee2e463fb2025-04-17 11:22:22 -0700410 m := AgentMessage{
411 Type: ToolUseMessageType,
412 Content: content.Text,
413 ToolResult: content.ToolResult,
414 ToolError: content.ToolError,
415 ToolName: toolName,
416 ToolInput: string(toolInput),
417 ToolCallId: content.ToolUseID,
418 StartTime: content.StartTime,
419 EndTime: content.EndTime,
420 }
421
422 // Calculate the elapsed time if both start and end times are set
423 if content.StartTime != nil && content.EndTime != nil {
424 elapsed := content.EndTime.Sub(*content.StartTime)
425 m.Elapsed = &elapsed
426 }
427
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700428 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700429 a.pushToOutbox(ctx, m)
430}
431
432// OnRequest implements ant.Listener.
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000433func (a *Agent) OnRequest(ctx context.Context, convo *ant.Convo, id string, msg *ant.Message) {
434 a.mu.Lock()
435 defer a.mu.Unlock()
436 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700437 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
438}
439
440// OnResponse implements ant.Listener. Responses contain messages from the LLM
441// that need to be displayed (as well as tool calls that we send along when
442// they're done). (It would be reasonable to also mention tool calls when they're
443// started, but we don't do that yet.)
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000444func (a *Agent) OnResponse(ctx context.Context, convo *ant.Convo, id string, resp *ant.MessageResponse) {
445 // Remove the LLM call from outstanding calls
446 a.mu.Lock()
447 delete(a.outstandingLLMCalls, id)
448 a.mu.Unlock()
449
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700450 if resp == nil {
451 // LLM API call failed
452 m := AgentMessage{
453 Type: ErrorMessageType,
454 Content: "API call failed, type 'continue' to try again",
455 }
456 m.SetConvo(convo)
457 a.pushToOutbox(ctx, m)
458 return
459 }
460
Earl Lee2e463fb2025-04-17 11:22:22 -0700461 endOfTurn := false
462 if resp.StopReason != ant.StopReasonToolUse {
463 endOfTurn = true
464 }
465 m := AgentMessage{
466 Type: AgentMessageType,
467 Content: collectTextContent(resp),
468 EndOfTurn: endOfTurn,
469 Usage: &resp.Usage,
470 StartTime: resp.StartTime,
471 EndTime: resp.EndTime,
472 }
473
474 // Extract any tool calls from the response
475 if resp.StopReason == ant.StopReasonToolUse {
476 var toolCalls []ToolCall
477 for _, part := range resp.Content {
478 if part.Type == "tool_use" {
479 toolCalls = append(toolCalls, ToolCall{
480 Name: part.ToolName,
481 Input: string(part.ToolInput),
482 ToolCallId: part.ID,
483 })
484 }
485 }
486 m.ToolCalls = toolCalls
487 }
488
489 // Calculate the elapsed time if both start and end times are set
490 if resp.StartTime != nil && resp.EndTime != nil {
491 elapsed := resp.EndTime.Sub(*resp.StartTime)
492 m.Elapsed = &elapsed
493 }
494
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700495 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700496 a.pushToOutbox(ctx, m)
497}
498
499// WorkingDir implements CodingAgent.
500func (a *Agent) WorkingDir() string {
501 return a.workingDir
502}
503
504// MessageCount implements CodingAgent.
505func (a *Agent) MessageCount() int {
506 a.mu.Lock()
507 defer a.mu.Unlock()
508 return len(a.history)
509}
510
511// Messages implements CodingAgent.
512func (a *Agent) Messages(start int, end int) []AgentMessage {
513 a.mu.Lock()
514 defer a.mu.Unlock()
515 return slices.Clone(a.history[start:end])
516}
517
518func (a *Agent) OriginalBudget() ant.Budget {
519 return a.originalBudget
520}
521
522// AgentConfig contains configuration for creating a new Agent.
523type AgentConfig struct {
524 Context context.Context
525 AntURL string
526 APIKey string
527 HTTPC *http.Client
528 Budget ant.Budget
529 GitUsername string
530 GitEmail string
531 SessionID string
532 ClientGOOS string
533 ClientGOARCH string
534 UseAnthropicEdit bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000535 // Outside information
536 OutsideHostname string
537 OutsideOS string
538 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700539}
540
541// NewAgent creates a new Agent.
542// It is not usable until Init() is called.
543func NewAgent(config AgentConfig) *Agent {
544 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000545 config: config,
546 ready: make(chan struct{}),
547 inbox: make(chan string, 100),
548 outbox: make(chan AgentMessage, 100),
549 startedAt: time.Now(),
550 originalBudget: config.Budget,
551 seenCommits: make(map[string]bool),
552 outsideHostname: config.OutsideHostname,
553 outsideOS: config.OutsideOS,
554 outsideWorkingDir: config.OutsideWorkingDir,
555 outstandingLLMCalls: make(map[string]struct{}),
556 outstandingToolCalls: make(map[string]string),
Earl Lee2e463fb2025-04-17 11:22:22 -0700557 }
558 return agent
559}
560
561type AgentInit struct {
562 WorkingDir string
563 NoGit bool // only for testing
564
565 InDocker bool
566 Commit string
567 GitRemoteAddr string
568 HostAddr string
569}
570
571func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700572 if a.convo != nil {
573 return fmt.Errorf("Agent.Init: already initialized")
574 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700575 ctx := a.config.Context
576 if ini.InDocker {
577 cmd := exec.CommandContext(ctx, "git", "stash")
578 cmd.Dir = ini.WorkingDir
579 if out, err := cmd.CombinedOutput(); err != nil {
580 return fmt.Errorf("git stash: %s: %v", out, err)
581 }
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700582 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
583 cmd.Dir = ini.WorkingDir
584 if out, err := cmd.CombinedOutput(); err != nil {
585 return fmt.Errorf("git remote add: %s: %v", out, err)
586 }
587 cmd = exec.CommandContext(ctx, "git", "fetch", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700588 cmd.Dir = ini.WorkingDir
589 if out, err := cmd.CombinedOutput(); err != nil {
590 return fmt.Errorf("git fetch: %s: %w", out, err)
591 }
592 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
593 cmd.Dir = ini.WorkingDir
594 if out, err := cmd.CombinedOutput(); err != nil {
595 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
596 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700597 a.lastHEAD = ini.Commit
598 a.gitRemoteAddr = ini.GitRemoteAddr
599 a.initialCommit = ini.Commit
600 if ini.HostAddr != "" {
601 a.url = "http://" + ini.HostAddr
602 }
603 }
604 a.workingDir = ini.WorkingDir
605
606 if !ini.NoGit {
607 repoRoot, err := repoRoot(ctx, a.workingDir)
608 if err != nil {
609 return fmt.Errorf("repoRoot: %w", err)
610 }
611 a.repoRoot = repoRoot
612
613 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
614 if err != nil {
615 return fmt.Errorf("resolveRef: %w", err)
616 }
617 a.initialCommit = commitHash
618
619 codereview, err := claudetool.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit)
620 if err != nil {
621 return fmt.Errorf("Agent.Init: claudetool.NewCodeReviewer: %w", err)
622 }
623 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000624
625 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700626 }
627 a.lastHEAD = a.initialCommit
628 a.convo = a.initConvo()
629 close(a.ready)
630 return nil
631}
632
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700633//go:embed agent_system_prompt.txt
634var agentSystemPrompt string
635
Earl Lee2e463fb2025-04-17 11:22:22 -0700636// initConvo initializes the conversation.
637// It must not be called until all agent fields are initialized,
638// particularly workingDir and git.
639func (a *Agent) initConvo() *ant.Convo {
640 ctx := a.config.Context
641 convo := ant.NewConvo(ctx, a.config.APIKey)
642 if a.config.HTTPC != nil {
643 convo.HTTPC = a.config.HTTPC
644 }
645 if a.config.AntURL != "" {
646 convo.URL = a.config.AntURL
647 }
648 convo.PromptCaching = true
649 convo.Budget = a.config.Budget
650
651 var editPrompt string
652 if a.config.UseAnthropicEdit {
653 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."
654 } else {
655 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
656 }
657
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700658 convo.SystemPrompt = fmt.Sprintf(agentSystemPrompt, editPrompt, a.config.ClientGOOS, a.config.ClientGOARCH, a.workingDir, a.repoRoot, a.initialCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -0700659
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000660 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
661 bashPermissionCheck := func(command string) error {
662 // Check if branch name is set
663 a.mu.Lock()
664 branchSet := a.branchName != ""
665 a.mu.Unlock()
666
667 // If branch is set, all commands are allowed
668 if branchSet {
669 return nil
670 }
671
672 // If branch is not set, check if this is a git commit command
673 willCommit, err := bashkit.WillRunGitCommit(command)
674 if err != nil {
675 // If there's an error checking, we should allow the command to proceed
676 return nil
677 }
678
679 // If it's a git commit and branch is not set, return an error
680 if willCommit {
681 return fmt.Errorf("you must use the title tool before making git commits")
682 }
683
684 return nil
685 }
686
687 // Create a custom bash tool with the permission check
688 bashTool := claudetool.NewBashTool(bashPermissionCheck)
689
Earl Lee2e463fb2025-04-17 11:22:22 -0700690 // Register all tools with the conversation
691 // When adding, removing, or modifying tools here, double-check that the termui tool display
692 // template in termui/termui.go has pretty-printing support for all tools.
693 convo.Tools = []*ant.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000694 bashTool, claudetool.Keyword,
Earl Lee2e463fb2025-04-17 11:22:22 -0700695 claudetool.Think, a.titleTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
696 a.codereview.Tool(),
697 }
698 if a.config.UseAnthropicEdit {
699 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
700 } else {
701 convo.Tools = append(convo.Tools, claudetool.Patch)
702 }
703 convo.Listener = a
704 return convo
705}
706
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000707// branchExists reports whether branchName exists, either locally or in well-known remotes.
708func branchExists(dir, branchName string) bool {
709 refs := []string{
710 "refs/heads/",
711 "refs/remotes/origin/",
712 "refs/remotes/sketch-host/",
713 }
714 for _, ref := range refs {
715 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
716 cmd.Dir = dir
717 if cmd.Run() == nil { // exit code 0 means branch exists
718 return true
719 }
720 }
721 return false
722}
723
Earl Lee2e463fb2025-04-17 11:22:22 -0700724func (a *Agent) titleTool() *ant.Tool {
Earl Lee2e463fb2025-04-17 11:22:22 -0700725 title := &ant.Tool{
726 Name: "title",
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700727 Description: `Sets the conversation title and creates a git branch for tracking work. MANDATORY: You must use this tool before making any git commits.`,
Earl Lee2e463fb2025-04-17 11:22:22 -0700728 InputSchema: json.RawMessage(`{
729 "type": "object",
730 "properties": {
731 "title": {
732 "type": "string",
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700733 "description": "A concise, descriptive title summarizing what this conversation is about"
734 },
735 "branch_name": {
736 "type": "string",
737 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
Earl Lee2e463fb2025-04-17 11:22:22 -0700738 }
739 },
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700740 "required": ["title", "branch_name"]
Earl Lee2e463fb2025-04-17 11:22:22 -0700741}`),
742 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
743 var params struct {
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700744 Title string `json:"title"`
745 BranchName string `json:"branch_name"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700746 }
747 if err := json.Unmarshal(input, &params); err != nil {
748 return "", err
749 }
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700750 // It's unfortunate to not allow title changes,
751 // but it avoids having multiple branches.
752 t := a.Title()
753 if t != "" {
754 return "", fmt.Errorf("title already set to: %s", t)
755 }
756
757 if params.BranchName == "" {
758 return "", fmt.Errorf("branch_name parameter cannot be empty")
759 }
760 if params.Title == "" {
761 return "", fmt.Errorf("title parameter cannot be empty")
762 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -0700763 if params.BranchName != cleanBranchName(params.BranchName) {
764 return "", fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
765 }
766 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000767 if branchExists(a.workingDir, branchName) {
768 return "", fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
769 }
770
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700771 a.SetTitleBranch(params.Title, branchName)
772
773 response := fmt.Sprintf("Title set to %q, branch name set to %q", params.Title, branchName)
774 return response, nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700775 },
776 }
777 return title
778}
779
780func (a *Agent) Ready() <-chan struct{} {
781 return a.ready
782}
783
784func (a *Agent) UserMessage(ctx context.Context, msg string) {
785 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
786 a.inbox <- msg
787}
788
789func (a *Agent) WaitForMessage(ctx context.Context) AgentMessage {
790 // TODO: Should this drain any outbox messages in case there are multiple?
791 select {
792 case msg := <-a.outbox:
793 return msg
794 case <-ctx.Done():
795 return errorMessage(ctx.Err())
796 }
797}
798
799func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
800 return a.convo.CancelToolUse(toolUseID, cause)
801}
802
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000803func (a *Agent) CancelTurn(cause error) {
804 a.cancelTurnMu.Lock()
805 defer a.cancelTurnMu.Unlock()
806 if a.cancelTurn != nil {
807 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -0700808 }
809}
810
811func (a *Agent) Loop(ctxOuter context.Context) {
812 for {
813 select {
814 case <-ctxOuter.Done():
815 return
816 default:
817 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000818 a.cancelTurnMu.Lock()
819 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +0000820 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000821 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -0700822 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000823 a.cancelTurn = cancel
824 a.cancelTurnMu.Unlock()
Sean McCullough885a16a2025-04-30 02:49:25 +0000825 a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
Earl Lee2e463fb2025-04-17 11:22:22 -0700826 cancel(nil)
827 }
828 }
829}
830
831func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
832 if m.Timestamp.IsZero() {
833 m.Timestamp = time.Now()
834 }
835
836 // If this is an end-of-turn message, calculate the turn duration and add it to the message
837 if m.EndOfTurn && m.Type == AgentMessageType {
838 turnDuration := time.Since(a.startOfTurn)
839 m.TurnDuration = &turnDuration
840 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
841 }
842
843 slog.InfoContext(ctx, "agent message", m.Attr())
844
845 a.mu.Lock()
846 defer a.mu.Unlock()
847 m.Idx = len(a.history)
848 a.history = append(a.history, m)
849 a.outbox <- m
850
851 // Notify all listeners:
852 for _, ch := range a.listeners {
853 close(ch)
854 }
855 a.listeners = a.listeners[:0]
856}
857
858func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]ant.Content, error) {
859 var m []ant.Content
860 if block {
861 select {
862 case <-ctx.Done():
863 return m, ctx.Err()
864 case msg := <-a.inbox:
865 m = append(m, ant.Content{Type: "text", Text: msg})
866 }
867 }
868 for {
869 select {
870 case msg := <-a.inbox:
871 m = append(m, ant.Content{Type: "text", Text: msg})
872 default:
873 return m, nil
874 }
875 }
876}
877
Sean McCullough885a16a2025-04-30 02:49:25 +0000878// processTurn handles a single conversation turn with the user
879func (a *Agent) processTurn(ctx context.Context) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700880 // Reset the start of turn time
881 a.startOfTurn = time.Now()
882
Sean McCullough885a16a2025-04-30 02:49:25 +0000883 // Process initial user message
884 initialResp, err := a.processUserMessage(ctx)
885 if err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700886 return
887 }
Sean McCullough885a16a2025-04-30 02:49:25 +0000888
Earl Lee2e463fb2025-04-17 11:22:22 -0700889 // We do this as we go, but let's also do it at the end of the turn
890 defer func() {
891 if _, err := a.handleGitCommits(ctx); err != nil {
892 // Just log the error, don't stop execution
893 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
894 }
895 }()
896
Sean McCullough885a16a2025-04-30 02:49:25 +0000897 // Main response loop - continue as long as the model is using tools
898 resp := initialResp
899 for {
900 // Check if we are over budget
901 if err := a.overBudget(ctx); err != nil {
902 return
903 }
904
905 // If the model is not requesting to use a tool, we're done
906 if resp.StopReason != ant.StopReasonToolUse {
907 break
908 }
909
910 // Handle tool execution
911 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
912 if !continueConversation {
913 return
914 }
915
916 // Set the response for the next iteration
917 resp = toolResp
918 }
919}
920
921// processUserMessage waits for user messages and sends them to the model
922func (a *Agent) processUserMessage(ctx context.Context) (*ant.MessageResponse, error) {
923 // Wait for at least one message from the user
924 msgs, err := a.GatherMessages(ctx, true)
925 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
926 return nil, err
927 }
928
Earl Lee2e463fb2025-04-17 11:22:22 -0700929 userMessage := ant.Message{
930 Role: "user",
931 Content: msgs,
932 }
Sean McCullough885a16a2025-04-30 02:49:25 +0000933
934 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -0700935 resp, err := a.convo.SendMessage(userMessage)
936 if err != nil {
937 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +0000938 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -0700939 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700940
Sean McCullough885a16a2025-04-30 02:49:25 +0000941 return resp, nil
942}
943
944// handleToolExecution processes a tool use request from the model
945func (a *Agent) handleToolExecution(ctx context.Context, resp *ant.MessageResponse) (bool, *ant.MessageResponse) {
946 var results []ant.Content
947 cancelled := false
948
949 // Check if the operation was cancelled by the user
950 select {
951 case <-ctx.Done():
952 // Don't actually run any of the tools, but rather build a response
953 // for each tool_use message letting the LLM know that user canceled it.
954 var err error
955 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -0700956 if err != nil {
Sean McCullough885a16a2025-04-30 02:49:25 +0000957 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -0700958 }
Sean McCullough885a16a2025-04-30 02:49:25 +0000959 cancelled = true
960 default:
961 // Add working directory to context for tool execution
962 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
963
964 // Execute the tools
965 var err error
966 results, err = a.convo.ToolResultContents(ctx, resp)
967 if ctx.Err() != nil { // e.g. the user canceled the operation
968 cancelled = true
969 } else if err != nil {
970 a.pushToOutbox(ctx, errorMessage(err))
971 }
972 }
973
974 // Process git commits that may have occurred during tool execution
975 autoqualityMessages := a.processGitChanges(ctx)
976
977 // Check budget again after tool execution
978 if err := a.overBudget(ctx); err != nil {
979 return false, nil
980 }
981
982 // Continue the conversation with tool results and any user messages
983 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
984}
985
986// processGitChanges checks for new git commits and runs autoformatters if needed
987func (a *Agent) processGitChanges(ctx context.Context) []string {
988 // Check for git commits after tool execution
989 newCommits, err := a.handleGitCommits(ctx)
990 if err != nil {
991 // Just log the error, don't stop execution
992 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
993 return nil
994 }
995
996 // Run autoformatters if there was exactly one new commit
997 var autoqualityMessages []string
998 if len(newCommits) == 1 {
999 formatted := a.codereview.Autoformat(ctx)
1000 if len(formatted) > 0 {
1001 msg := fmt.Sprintf(`
Earl Lee2e463fb2025-04-17 11:22:22 -07001002I ran autoformatters and they updated these files:
1003
1004%s
1005
1006Please amend your latest git commit with these changes and then continue with what you were doing.`,
Sean McCullough885a16a2025-04-30 02:49:25 +00001007 strings.Join(formatted, "\n"),
1008 )[1:]
1009 a.pushToOutbox(ctx, AgentMessage{
1010 Type: AutoMessageType,
1011 Content: msg,
1012 Timestamp: time.Now(),
1013 })
1014 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001015 }
1016 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001017
1018 return autoqualityMessages
1019}
1020
1021// continueTurnWithToolResults continues the conversation with tool results
1022func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []ant.Content, autoqualityMessages []string, cancelled bool) (bool, *ant.MessageResponse) {
1023 // Get any messages the user sent while tools were executing
1024 msgs, err := a.GatherMessages(ctx, false)
1025 if err != nil {
1026 return false, nil
1027 }
1028
1029 // Inject any auto-generated messages from quality checks
1030 for _, msg := range autoqualityMessages {
1031 msgs = append(msgs, ant.Content{Type: "text", Text: msg})
1032 }
1033
1034 // Handle cancellation by appending a message about it
1035 if cancelled {
1036 msgs = append(msgs, ant.Content{Type: "text", Text: cancelToolUseMessage})
1037 // EndOfTurn is false here so that the client of this agent keeps processing
1038 // messages from WaitForMessage() and gets the response from the LLM
1039 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1040 } else if err := a.convo.OverBudget(); err != nil {
1041 // Handle budget issues by appending a message about it
1042 budgetMsg := "We've exceeded our budget. Please ask the user to confirm before continuing by ending the turn."
1043 msgs = append(msgs, ant.Content{Type: "text", Text: budgetMsg})
1044 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1045 }
1046
1047 // Combine tool results with user messages
1048 results = append(results, msgs...)
1049
1050 // Send the combined message to continue the conversation
1051 resp, err := a.convo.SendMessage(ant.Message{
1052 Role: "user",
1053 Content: results,
1054 })
1055 if err != nil {
1056 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1057 return true, nil // Return true to continue the conversation, but with no response
1058 }
1059
1060 if cancelled {
1061 return false, nil
1062 }
1063
1064 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001065}
1066
1067func (a *Agent) overBudget(ctx context.Context) error {
1068 if err := a.convo.OverBudget(); err != nil {
1069 m := budgetMessage(err)
1070 m.Content = m.Content + "\n\nBudget reset."
1071 a.pushToOutbox(ctx, budgetMessage(err))
1072 a.convo.ResetBudget(a.originalBudget)
1073 return err
1074 }
1075 return nil
1076}
1077
1078func collectTextContent(msg *ant.MessageResponse) string {
1079 // Collect all text content
1080 var allText strings.Builder
1081 for _, content := range msg.Content {
1082 if content.Type == "text" && content.Text != "" {
1083 if allText.Len() > 0 {
1084 allText.WriteString("\n\n")
1085 }
1086 allText.WriteString(content.Text)
1087 }
1088 }
1089 return allText.String()
1090}
1091
1092func (a *Agent) TotalUsage() ant.CumulativeUsage {
1093 a.mu.Lock()
1094 defer a.mu.Unlock()
1095 return a.convo.CumulativeUsage()
1096}
1097
1098// WaitForMessageCount returns when the agent has at more than clientMessageCount messages or the context is done.
1099func (a *Agent) WaitForMessageCount(ctx context.Context, greaterThan int) {
1100 for a.MessageCount() <= greaterThan {
1101 a.mu.Lock()
1102 ch := make(chan struct{})
1103 // Deletion happens when we notify.
1104 a.listeners = append(a.listeners, ch)
1105 a.mu.Unlock()
1106
1107 select {
1108 case <-ctx.Done():
1109 return
1110 case <-ch:
1111 continue
1112 }
1113 }
1114}
1115
1116// Diff returns a unified diff of changes made since the agent was instantiated.
1117func (a *Agent) Diff(commit *string) (string, error) {
1118 if a.initialCommit == "" {
1119 return "", fmt.Errorf("no initial commit reference available")
1120 }
1121
1122 // Find the repository root
1123 ctx := context.Background()
1124
1125 // If a specific commit hash is provided, show just that commit's changes
1126 if commit != nil && *commit != "" {
1127 // Validate that the commit looks like a valid git SHA
1128 if !isValidGitSHA(*commit) {
1129 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1130 }
1131
1132 // Get the diff for just this commit
1133 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1134 cmd.Dir = a.repoRoot
1135 output, err := cmd.CombinedOutput()
1136 if err != nil {
1137 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1138 }
1139 return string(output), nil
1140 }
1141
1142 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1143 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1144 cmd.Dir = a.repoRoot
1145 output, err := cmd.CombinedOutput()
1146 if err != nil {
1147 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1148 }
1149
1150 return string(output), nil
1151}
1152
1153// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1154func (a *Agent) InitialCommit() string {
1155 return a.initialCommit
1156}
1157
1158// handleGitCommits() highlights new commits to the user. When running
1159// under docker, new HEADs are pushed to a branch according to the title.
1160func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1161 if a.repoRoot == "" {
1162 return nil, nil
1163 }
1164
1165 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1166 if err != nil {
1167 return nil, err
1168 }
1169 if head == a.lastHEAD {
1170 return nil, nil // nothing to do
1171 }
1172 defer func() {
1173 a.lastHEAD = head
1174 }()
1175
1176 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1177 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1178 // to the last 100 commits.
1179 var commits []*GitCommit
1180
1181 // Get commits since the initial commit
1182 // Format: <hash>\0<subject>\0<body>\0
1183 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1184 // Limit to 100 commits to avoid overwhelming the user
1185 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1186 cmd.Dir = a.repoRoot
1187 output, err := cmd.Output()
1188 if err != nil {
1189 return nil, fmt.Errorf("failed to get git log: %w", err)
1190 }
1191
1192 // Parse git log output and filter out already seen commits
1193 parsedCommits := parseGitLog(string(output))
1194
1195 var headCommit *GitCommit
1196
1197 // Filter out commits we've already seen
1198 for _, commit := range parsedCommits {
1199 if commit.Hash == head {
1200 headCommit = &commit
1201 }
1202
1203 // Skip if we've seen this commit before. If our head has changed, always include that.
1204 if a.seenCommits[commit.Hash] && commit.Hash != head {
1205 continue
1206 }
1207
1208 // Mark this commit as seen
1209 a.seenCommits[commit.Hash] = true
1210
1211 // Add to our list of new commits
1212 commits = append(commits, &commit)
1213 }
1214
1215 if a.gitRemoteAddr != "" {
1216 if headCommit == nil {
1217 // I think this can only happen if we have a bug or if there's a race.
1218 headCommit = &GitCommit{}
1219 headCommit.Hash = head
1220 headCommit.Subject = "unknown"
1221 commits = append(commits, headCommit)
1222 }
1223
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001224 branch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
Earl Lee2e463fb2025-04-17 11:22:22 -07001225
1226 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1227 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1228 // then use push with lease to replace.
1229 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1230 cmd.Dir = a.workingDir
1231 if out, err := cmd.CombinedOutput(); err != nil {
1232 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1233 } else {
1234 headCommit.PushedBranch = branch
1235 }
1236 }
1237
1238 // If we found new commits, create a message
1239 if len(commits) > 0 {
1240 msg := AgentMessage{
1241 Type: CommitMessageType,
1242 Timestamp: time.Now(),
1243 Commits: commits,
1244 }
1245 a.pushToOutbox(ctx, msg)
1246 }
1247 return commits, nil
1248}
1249
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001250func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001251 return strings.Map(func(r rune) rune {
1252 // lowercase
1253 if r >= 'A' && r <= 'Z' {
1254 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001255 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001256 // replace spaces with dashes
1257 if r == ' ' {
1258 return '-'
1259 }
1260 // allow alphanumerics and dashes
1261 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1262 return r
1263 }
1264 return -1
1265 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001266}
1267
1268// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1269// and returns an array of GitCommit structs.
1270func parseGitLog(output string) []GitCommit {
1271 var commits []GitCommit
1272
1273 // No output means no commits
1274 if len(output) == 0 {
1275 return commits
1276 }
1277
1278 // Split by NULL byte
1279 parts := strings.Split(output, "\x00")
1280
1281 // Process in triplets (hash, subject, body)
1282 for i := 0; i < len(parts); i++ {
1283 // Skip empty parts
1284 if parts[i] == "" {
1285 continue
1286 }
1287
1288 // This should be a hash
1289 hash := strings.TrimSpace(parts[i])
1290
1291 // Make sure we have at least a subject part available
1292 if i+1 >= len(parts) {
1293 break // No more parts available
1294 }
1295
1296 // Get the subject
1297 subject := strings.TrimSpace(parts[i+1])
1298
1299 // Get the body if available
1300 body := ""
1301 if i+2 < len(parts) {
1302 body = strings.TrimSpace(parts[i+2])
1303 }
1304
1305 // Skip to the next triplet
1306 i += 2
1307
1308 commits = append(commits, GitCommit{
1309 Hash: hash,
1310 Subject: subject,
1311 Body: body,
1312 })
1313 }
1314
1315 return commits
1316}
1317
1318func repoRoot(ctx context.Context, dir string) (string, error) {
1319 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1320 stderr := new(strings.Builder)
1321 cmd.Stderr = stderr
1322 cmd.Dir = dir
1323 out, err := cmd.Output()
1324 if err != nil {
1325 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1326 }
1327 return strings.TrimSpace(string(out)), nil
1328}
1329
1330func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1331 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1332 stderr := new(strings.Builder)
1333 cmd.Stderr = stderr
1334 cmd.Dir = dir
1335 out, err := cmd.Output()
1336 if err != nil {
1337 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1338 }
1339 // TODO: validate that out is valid hex
1340 return strings.TrimSpace(string(out)), nil
1341}
1342
1343// isValidGitSHA validates if a string looks like a valid git SHA hash.
1344// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1345func isValidGitSHA(sha string) bool {
1346 // Git SHA must be a hexadecimal string with at least 4 characters
1347 if len(sha) < 4 || len(sha) > 40 {
1348 return false
1349 }
1350
1351 // Check if the string only contains hexadecimal characters
1352 for _, char := range sha {
1353 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1354 return false
1355 }
1356 }
1357
1358 return true
1359}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001360
1361// getGitOrigin returns the URL of the git remote 'origin' if it exists
1362func getGitOrigin(ctx context.Context, dir string) string {
1363 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1364 cmd.Dir = dir
1365 stderr := new(strings.Builder)
1366 cmd.Stderr = stderr
1367 out, err := cmd.Output()
1368 if err != nil {
1369 return ""
1370 }
1371 return strings.TrimSpace(string(out))
1372}