blob: 9b391d01dd13e544ca26f52d681ed19a2a8b4e12 [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
49 CancelInnerLoop(cause error)
50
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
77 // OS returns the operating system of the client.
78 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +000079
Philip Zeyligerc72fff52025-04-29 20:17:54 +000080 // SessionID returns the unique session identifier.
81 SessionID() string
82
Philip Zeyliger99a9a022025-04-27 15:15:25 +000083 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
84 OutstandingLLMCallCount() int
85
86 // OutstandingToolCalls returns the names of outstanding tool calls.
87 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +000088 OutsideOS() string
89 OutsideHostname() string
90 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +000091 GitOrigin() string
Earl Lee2e463fb2025-04-17 11:22:22 -070092}
93
94type CodingAgentMessageType string
95
96const (
97 UserMessageType CodingAgentMessageType = "user"
98 AgentMessageType CodingAgentMessageType = "agent"
99 ErrorMessageType CodingAgentMessageType = "error"
100 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
101 ToolUseMessageType CodingAgentMessageType = "tool"
102 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
103 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
104
105 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
106)
107
108type AgentMessage struct {
109 Type CodingAgentMessageType `json:"type"`
110 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
111 EndOfTurn bool `json:"end_of_turn"`
112
113 Content string `json:"content"`
114 ToolName string `json:"tool_name,omitempty"`
115 ToolInput string `json:"input,omitempty"`
116 ToolResult string `json:"tool_result,omitempty"`
117 ToolError bool `json:"tool_error,omitempty"`
118 ToolCallId string `json:"tool_call_id,omitempty"`
119
120 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
121 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
122
Sean McCulloughd9f13372025-04-21 15:08:49 -0700123 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
124 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
125
Earl Lee2e463fb2025-04-17 11:22:22 -0700126 // Commits is a list of git commits for a commit message
127 Commits []*GitCommit `json:"commits,omitempty"`
128
129 Timestamp time.Time `json:"timestamp"`
130 ConversationID string `json:"conversation_id"`
131 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
132 Usage *ant.Usage `json:"usage,omitempty"`
133
134 // Message timing information
135 StartTime *time.Time `json:"start_time,omitempty"`
136 EndTime *time.Time `json:"end_time,omitempty"`
137 Elapsed *time.Duration `json:"elapsed,omitempty"`
138
139 // Turn duration - the time taken for a complete agent turn
140 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
141
142 Idx int `json:"idx"`
143}
144
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700145// SetConvo sets m.ConversationID and m.ParentConversationID based on convo.
146func (m *AgentMessage) SetConvo(convo *ant.Convo) {
147 if convo == nil {
148 m.ConversationID = ""
149 m.ParentConversationID = nil
150 return
151 }
152 m.ConversationID = convo.ID
153 if convo.Parent != nil {
154 m.ParentConversationID = &convo.Parent.ID
155 }
156}
157
Earl Lee2e463fb2025-04-17 11:22:22 -0700158// GitCommit represents a single git commit for a commit message
159type GitCommit struct {
160 Hash string `json:"hash"` // Full commit hash
161 Subject string `json:"subject"` // Commit subject line
162 Body string `json:"body"` // Full commit message body
163 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
164}
165
166// ToolCall represents a single tool call within an agent message
167type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700168 Name string `json:"name"`
169 Input string `json:"input"`
170 ToolCallId string `json:"tool_call_id"`
171 ResultMessage *AgentMessage `json:"result_message,omitempty"`
172 Args string `json:"args,omitempty"`
173 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700174}
175
176func (a *AgentMessage) Attr() slog.Attr {
177 var attrs []any = []any{
178 slog.String("type", string(a.Type)),
179 }
180 if a.EndOfTurn {
181 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
182 }
183 if a.Content != "" {
184 attrs = append(attrs, slog.String("content", a.Content))
185 }
186 if a.ToolName != "" {
187 attrs = append(attrs, slog.String("tool_name", a.ToolName))
188 }
189 if a.ToolInput != "" {
190 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
191 }
192 if a.Elapsed != nil {
193 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
194 }
195 if a.TurnDuration != nil {
196 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
197 }
198 if a.ToolResult != "" {
199 attrs = append(attrs, slog.String("tool_result", a.ToolResult))
200 }
201 if a.ToolError {
202 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
203 }
204 if len(a.ToolCalls) > 0 {
205 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
206 for i, tc := range a.ToolCalls {
207 toolCallAttrs = append(toolCallAttrs, slog.Group(
208 fmt.Sprintf("tool_call_%d", i),
209 slog.String("name", tc.Name),
210 slog.String("input", tc.Input),
211 ))
212 }
213 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
214 }
215 if a.ConversationID != "" {
216 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
217 }
218 if a.ParentConversationID != nil {
219 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
220 }
221 if a.Usage != nil && !a.Usage.IsZero() {
222 attrs = append(attrs, a.Usage.Attr())
223 }
224 // TODO: timestamp, convo ids, idx?
225 return slog.Group("agent_message", attrs...)
226}
227
228func errorMessage(err error) AgentMessage {
229 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
230 if os.Getenv(("DEBUG")) == "1" {
231 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
232 }
233
234 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
235}
236
237func budgetMessage(err error) AgentMessage {
238 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
239}
240
241// ConvoInterface defines the interface for conversation interactions
242type ConvoInterface interface {
243 CumulativeUsage() ant.CumulativeUsage
244 ResetBudget(ant.Budget)
245 OverBudget() error
246 SendMessage(message ant.Message) (*ant.MessageResponse, error)
247 SendUserTextMessage(s string, otherContents ...ant.Content) (*ant.MessageResponse, error)
248 ToolResultContents(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error)
249 ToolResultCancelContents(resp *ant.MessageResponse) ([]ant.Content, error)
250 CancelToolUse(toolUseID string, cause error) error
251}
252
253type Agent struct {
254 convo ConvoInterface
255 config AgentConfig // config for this agent
256 workingDir string
257 repoRoot string // workingDir may be a subdir of repoRoot
258 url string
259 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
260 initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
261 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
262 ready chan struct{} // closed when the agent is initialized (only when under docker)
263 startedAt time.Time
264 originalBudget ant.Budget
265 title string
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700266 branchName string
Earl Lee2e463fb2025-04-17 11:22:22 -0700267 codereview *claudetool.CodeReviewer
Philip Zeyliger18532b22025-04-23 21:11:46 +0000268 // Outside information
269 outsideHostname string
270 outsideOS string
271 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000272 // URL of the git remote 'origin' if it exists
273 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700274
275 // Time when the current turn started (reset at the beginning of InnerLoop)
276 startOfTurn time.Time
277
278 // Inbox - for messages from the user to the agent.
279 // sent on by UserMessage
280 // . e.g. when user types into the chat textarea
281 // read from by GatherMessages
282 inbox chan string
283
284 // Outbox
285 // sent on by pushToOutbox
286 // via OnToolResult and OnResponse callbacks
287 // read from by WaitForMessage
288 // called by termui inside its repl loop.
289 outbox chan AgentMessage
290
291 // protects cancelInnerLoop
292 cancelInnerLoopMu sync.Mutex
293 // cancels potentially long-running tool_use calls or chains of them
294 cancelInnerLoop context.CancelCauseFunc
295
296 // protects following
297 mu sync.Mutex
298
299 // Stores all messages for this agent
300 history []AgentMessage
301
302 listeners []chan struct{}
303
304 // Track git commits we've already seen (by hash)
305 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000306
307 // Track outstanding LLM call IDs
308 outstandingLLMCalls map[string]struct{}
309
310 // Track outstanding tool calls by ID with their names
311 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700312}
313
314func (a *Agent) URL() string { return a.url }
315
316// Title returns the current title of the conversation.
317// If no title has been set, returns an empty string.
318func (a *Agent) Title() string {
319 a.mu.Lock()
320 defer a.mu.Unlock()
321 return a.title
322}
323
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000324// OutstandingLLMCallCount returns the number of outstanding LLM calls.
325func (a *Agent) OutstandingLLMCallCount() int {
326 a.mu.Lock()
327 defer a.mu.Unlock()
328 return len(a.outstandingLLMCalls)
329}
330
331// OutstandingToolCalls returns the names of outstanding tool calls.
332func (a *Agent) OutstandingToolCalls() []string {
333 a.mu.Lock()
334 defer a.mu.Unlock()
335
336 tools := make([]string, 0, len(a.outstandingToolCalls))
337 for _, toolName := range a.outstandingToolCalls {
338 tools = append(tools, toolName)
339 }
340 return tools
341}
342
Earl Lee2e463fb2025-04-17 11:22:22 -0700343// OS returns the operating system of the client.
344func (a *Agent) OS() string {
345 return a.config.ClientGOOS
346}
347
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000348func (a *Agent) SessionID() string {
349 return a.config.SessionID
350}
351
Philip Zeyliger18532b22025-04-23 21:11:46 +0000352// OutsideOS returns the operating system of the outside system.
353func (a *Agent) OutsideOS() string {
354 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000355}
356
Philip Zeyliger18532b22025-04-23 21:11:46 +0000357// OutsideHostname returns the hostname of the outside system.
358func (a *Agent) OutsideHostname() string {
359 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000360}
361
Philip Zeyliger18532b22025-04-23 21:11:46 +0000362// OutsideWorkingDir returns the working directory on the outside system.
363func (a *Agent) OutsideWorkingDir() string {
364 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000365}
366
367// GitOrigin returns the URL of the git remote 'origin' if it exists.
368func (a *Agent) GitOrigin() string {
369 return a.gitOrigin
370}
371
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700372// SetTitleBranch sets the title and branch name of the conversation.
373func (a *Agent) SetTitleBranch(title, branchName string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700374 a.mu.Lock()
375 defer a.mu.Unlock()
376 a.title = title
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700377 a.branchName = branchName
Earl Lee2e463fb2025-04-17 11:22:22 -0700378 // Notify all listeners that the state has changed
379 for _, ch := range a.listeners {
380 close(ch)
381 }
382 a.listeners = a.listeners[:0]
383}
384
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000385// OnToolCall implements ant.Listener and tracks the start of a tool call.
386func (a *Agent) OnToolCall(ctx context.Context, convo *ant.Convo, id string, toolName string, toolInput json.RawMessage, content ant.Content) {
387 // Track the tool call
388 a.mu.Lock()
389 a.outstandingToolCalls[id] = toolName
390 a.mu.Unlock()
391}
392
Earl Lee2e463fb2025-04-17 11:22:22 -0700393// OnToolResult implements ant.Listener.
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000394func (a *Agent) OnToolResult(ctx context.Context, convo *ant.Convo, toolID string, toolName string, toolInput json.RawMessage, content ant.Content, result *string, err error) {
395 // Remove the tool call from outstanding calls
396 a.mu.Lock()
397 delete(a.outstandingToolCalls, toolID)
398 a.mu.Unlock()
399
Earl Lee2e463fb2025-04-17 11:22:22 -0700400 m := AgentMessage{
401 Type: ToolUseMessageType,
402 Content: content.Text,
403 ToolResult: content.ToolResult,
404 ToolError: content.ToolError,
405 ToolName: toolName,
406 ToolInput: string(toolInput),
407 ToolCallId: content.ToolUseID,
408 StartTime: content.StartTime,
409 EndTime: content.EndTime,
410 }
411
412 // Calculate the elapsed time if both start and end times are set
413 if content.StartTime != nil && content.EndTime != nil {
414 elapsed := content.EndTime.Sub(*content.StartTime)
415 m.Elapsed = &elapsed
416 }
417
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700418 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700419 a.pushToOutbox(ctx, m)
420}
421
422// OnRequest implements ant.Listener.
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000423func (a *Agent) OnRequest(ctx context.Context, convo *ant.Convo, id string, msg *ant.Message) {
424 a.mu.Lock()
425 defer a.mu.Unlock()
426 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700427 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
428}
429
430// OnResponse implements ant.Listener. Responses contain messages from the LLM
431// that need to be displayed (as well as tool calls that we send along when
432// they're done). (It would be reasonable to also mention tool calls when they're
433// started, but we don't do that yet.)
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000434func (a *Agent) OnResponse(ctx context.Context, convo *ant.Convo, id string, resp *ant.MessageResponse) {
435 // Remove the LLM call from outstanding calls
436 a.mu.Lock()
437 delete(a.outstandingLLMCalls, id)
438 a.mu.Unlock()
439
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700440 if resp == nil {
441 // LLM API call failed
442 m := AgentMessage{
443 Type: ErrorMessageType,
444 Content: "API call failed, type 'continue' to try again",
445 }
446 m.SetConvo(convo)
447 a.pushToOutbox(ctx, m)
448 return
449 }
450
Earl Lee2e463fb2025-04-17 11:22:22 -0700451 endOfTurn := false
452 if resp.StopReason != ant.StopReasonToolUse {
453 endOfTurn = true
454 }
455 m := AgentMessage{
456 Type: AgentMessageType,
457 Content: collectTextContent(resp),
458 EndOfTurn: endOfTurn,
459 Usage: &resp.Usage,
460 StartTime: resp.StartTime,
461 EndTime: resp.EndTime,
462 }
463
464 // Extract any tool calls from the response
465 if resp.StopReason == ant.StopReasonToolUse {
466 var toolCalls []ToolCall
467 for _, part := range resp.Content {
468 if part.Type == "tool_use" {
469 toolCalls = append(toolCalls, ToolCall{
470 Name: part.ToolName,
471 Input: string(part.ToolInput),
472 ToolCallId: part.ID,
473 })
474 }
475 }
476 m.ToolCalls = toolCalls
477 }
478
479 // Calculate the elapsed time if both start and end times are set
480 if resp.StartTime != nil && resp.EndTime != nil {
481 elapsed := resp.EndTime.Sub(*resp.StartTime)
482 m.Elapsed = &elapsed
483 }
484
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700485 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700486 a.pushToOutbox(ctx, m)
487}
488
489// WorkingDir implements CodingAgent.
490func (a *Agent) WorkingDir() string {
491 return a.workingDir
492}
493
494// MessageCount implements CodingAgent.
495func (a *Agent) MessageCount() int {
496 a.mu.Lock()
497 defer a.mu.Unlock()
498 return len(a.history)
499}
500
501// Messages implements CodingAgent.
502func (a *Agent) Messages(start int, end int) []AgentMessage {
503 a.mu.Lock()
504 defer a.mu.Unlock()
505 return slices.Clone(a.history[start:end])
506}
507
508func (a *Agent) OriginalBudget() ant.Budget {
509 return a.originalBudget
510}
511
512// AgentConfig contains configuration for creating a new Agent.
513type AgentConfig struct {
514 Context context.Context
515 AntURL string
516 APIKey string
517 HTTPC *http.Client
518 Budget ant.Budget
519 GitUsername string
520 GitEmail string
521 SessionID string
522 ClientGOOS string
523 ClientGOARCH string
524 UseAnthropicEdit bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000525 // Outside information
526 OutsideHostname string
527 OutsideOS string
528 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700529}
530
531// NewAgent creates a new Agent.
532// It is not usable until Init() is called.
533func NewAgent(config AgentConfig) *Agent {
534 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000535 config: config,
536 ready: make(chan struct{}),
537 inbox: make(chan string, 100),
538 outbox: make(chan AgentMessage, 100),
539 startedAt: time.Now(),
540 originalBudget: config.Budget,
541 seenCommits: make(map[string]bool),
542 outsideHostname: config.OutsideHostname,
543 outsideOS: config.OutsideOS,
544 outsideWorkingDir: config.OutsideWorkingDir,
545 outstandingLLMCalls: make(map[string]struct{}),
546 outstandingToolCalls: make(map[string]string),
Earl Lee2e463fb2025-04-17 11:22:22 -0700547 }
548 return agent
549}
550
551type AgentInit struct {
552 WorkingDir string
553 NoGit bool // only for testing
554
555 InDocker bool
556 Commit string
557 GitRemoteAddr string
558 HostAddr string
559}
560
561func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700562 if a.convo != nil {
563 return fmt.Errorf("Agent.Init: already initialized")
564 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700565 ctx := a.config.Context
566 if ini.InDocker {
567 cmd := exec.CommandContext(ctx, "git", "stash")
568 cmd.Dir = ini.WorkingDir
569 if out, err := cmd.CombinedOutput(); err != nil {
570 return fmt.Errorf("git stash: %s: %v", out, err)
571 }
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700572 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
573 cmd.Dir = ini.WorkingDir
574 if out, err := cmd.CombinedOutput(); err != nil {
575 return fmt.Errorf("git remote add: %s: %v", out, err)
576 }
577 cmd = exec.CommandContext(ctx, "git", "fetch", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700578 cmd.Dir = ini.WorkingDir
579 if out, err := cmd.CombinedOutput(); err != nil {
580 return fmt.Errorf("git fetch: %s: %w", out, err)
581 }
582 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
583 cmd.Dir = ini.WorkingDir
584 if out, err := cmd.CombinedOutput(); err != nil {
585 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
586 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700587 a.lastHEAD = ini.Commit
588 a.gitRemoteAddr = ini.GitRemoteAddr
589 a.initialCommit = ini.Commit
590 if ini.HostAddr != "" {
591 a.url = "http://" + ini.HostAddr
592 }
593 }
594 a.workingDir = ini.WorkingDir
595
596 if !ini.NoGit {
597 repoRoot, err := repoRoot(ctx, a.workingDir)
598 if err != nil {
599 return fmt.Errorf("repoRoot: %w", err)
600 }
601 a.repoRoot = repoRoot
602
603 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
604 if err != nil {
605 return fmt.Errorf("resolveRef: %w", err)
606 }
607 a.initialCommit = commitHash
608
609 codereview, err := claudetool.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit)
610 if err != nil {
611 return fmt.Errorf("Agent.Init: claudetool.NewCodeReviewer: %w", err)
612 }
613 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000614
615 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700616 }
617 a.lastHEAD = a.initialCommit
618 a.convo = a.initConvo()
619 close(a.ready)
620 return nil
621}
622
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700623//go:embed agent_system_prompt.txt
624var agentSystemPrompt string
625
Earl Lee2e463fb2025-04-17 11:22:22 -0700626// initConvo initializes the conversation.
627// It must not be called until all agent fields are initialized,
628// particularly workingDir and git.
629func (a *Agent) initConvo() *ant.Convo {
630 ctx := a.config.Context
631 convo := ant.NewConvo(ctx, a.config.APIKey)
632 if a.config.HTTPC != nil {
633 convo.HTTPC = a.config.HTTPC
634 }
635 if a.config.AntURL != "" {
636 convo.URL = a.config.AntURL
637 }
638 convo.PromptCaching = true
639 convo.Budget = a.config.Budget
640
641 var editPrompt string
642 if a.config.UseAnthropicEdit {
643 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."
644 } else {
645 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
646 }
647
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700648 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 -0700649
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000650 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
651 bashPermissionCheck := func(command string) error {
652 // Check if branch name is set
653 a.mu.Lock()
654 branchSet := a.branchName != ""
655 a.mu.Unlock()
656
657 // If branch is set, all commands are allowed
658 if branchSet {
659 return nil
660 }
661
662 // If branch is not set, check if this is a git commit command
663 willCommit, err := bashkit.WillRunGitCommit(command)
664 if err != nil {
665 // If there's an error checking, we should allow the command to proceed
666 return nil
667 }
668
669 // If it's a git commit and branch is not set, return an error
670 if willCommit {
671 return fmt.Errorf("you must use the title tool before making git commits")
672 }
673
674 return nil
675 }
676
677 // Create a custom bash tool with the permission check
678 bashTool := claudetool.NewBashTool(bashPermissionCheck)
679
Earl Lee2e463fb2025-04-17 11:22:22 -0700680 // Register all tools with the conversation
681 // When adding, removing, or modifying tools here, double-check that the termui tool display
682 // template in termui/termui.go has pretty-printing support for all tools.
683 convo.Tools = []*ant.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000684 bashTool, claudetool.Keyword,
Earl Lee2e463fb2025-04-17 11:22:22 -0700685 claudetool.Think, a.titleTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
686 a.codereview.Tool(),
687 }
688 if a.config.UseAnthropicEdit {
689 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
690 } else {
691 convo.Tools = append(convo.Tools, claudetool.Patch)
692 }
693 convo.Listener = a
694 return convo
695}
696
697func (a *Agent) titleTool() *ant.Tool {
Earl Lee2e463fb2025-04-17 11:22:22 -0700698 title := &ant.Tool{
699 Name: "title",
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700700 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 -0700701 InputSchema: json.RawMessage(`{
702 "type": "object",
703 "properties": {
704 "title": {
705 "type": "string",
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700706 "description": "A concise, descriptive title summarizing what this conversation is about"
707 },
708 "branch_name": {
709 "type": "string",
710 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
Earl Lee2e463fb2025-04-17 11:22:22 -0700711 }
712 },
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700713 "required": ["title", "branch_name"]
Earl Lee2e463fb2025-04-17 11:22:22 -0700714}`),
715 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
716 var params struct {
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700717 Title string `json:"title"`
718 BranchName string `json:"branch_name"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700719 }
720 if err := json.Unmarshal(input, &params); err != nil {
721 return "", err
722 }
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700723 // It's unfortunate to not allow title changes,
724 // but it avoids having multiple branches.
725 t := a.Title()
726 if t != "" {
727 return "", fmt.Errorf("title already set to: %s", t)
728 }
729
730 if params.BranchName == "" {
731 return "", fmt.Errorf("branch_name parameter cannot be empty")
732 }
733 if params.Title == "" {
734 return "", fmt.Errorf("title parameter cannot be empty")
735 }
736
737 branchName := "sketch/" + cleanBranchName(params.BranchName)
738 a.SetTitleBranch(params.Title, branchName)
739
740 response := fmt.Sprintf("Title set to %q, branch name set to %q", params.Title, branchName)
741 return response, nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700742 },
743 }
744 return title
745}
746
747func (a *Agent) Ready() <-chan struct{} {
748 return a.ready
749}
750
751func (a *Agent) UserMessage(ctx context.Context, msg string) {
752 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
753 a.inbox <- msg
754}
755
756func (a *Agent) WaitForMessage(ctx context.Context) AgentMessage {
757 // TODO: Should this drain any outbox messages in case there are multiple?
758 select {
759 case msg := <-a.outbox:
760 return msg
761 case <-ctx.Done():
762 return errorMessage(ctx.Err())
763 }
764}
765
766func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
767 return a.convo.CancelToolUse(toolUseID, cause)
768}
769
770func (a *Agent) CancelInnerLoop(cause error) {
771 a.cancelInnerLoopMu.Lock()
772 defer a.cancelInnerLoopMu.Unlock()
773 if a.cancelInnerLoop != nil {
774 a.cancelInnerLoop(cause)
775 }
776}
777
778func (a *Agent) Loop(ctxOuter context.Context) {
779 for {
780 select {
781 case <-ctxOuter.Done():
782 return
783 default:
784 ctxInner, cancel := context.WithCancelCause(ctxOuter)
785 a.cancelInnerLoopMu.Lock()
786 // Set .cancelInnerLoop so the user can cancel whatever is happening
787 // inside InnerLoop(ctxInner) without canceling this outer Loop execution.
788 // This CancelInnerLoop func is intended be called from other goroutines,
789 // hence the mutex.
790 a.cancelInnerLoop = cancel
791 a.cancelInnerLoopMu.Unlock()
792 a.InnerLoop(ctxInner)
793 cancel(nil)
794 }
795 }
796}
797
798func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
799 if m.Timestamp.IsZero() {
800 m.Timestamp = time.Now()
801 }
802
803 // If this is an end-of-turn message, calculate the turn duration and add it to the message
804 if m.EndOfTurn && m.Type == AgentMessageType {
805 turnDuration := time.Since(a.startOfTurn)
806 m.TurnDuration = &turnDuration
807 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
808 }
809
810 slog.InfoContext(ctx, "agent message", m.Attr())
811
812 a.mu.Lock()
813 defer a.mu.Unlock()
814 m.Idx = len(a.history)
815 a.history = append(a.history, m)
816 a.outbox <- m
817
818 // Notify all listeners:
819 for _, ch := range a.listeners {
820 close(ch)
821 }
822 a.listeners = a.listeners[:0]
823}
824
825func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]ant.Content, error) {
826 var m []ant.Content
827 if block {
828 select {
829 case <-ctx.Done():
830 return m, ctx.Err()
831 case msg := <-a.inbox:
832 m = append(m, ant.Content{Type: "text", Text: msg})
833 }
834 }
835 for {
836 select {
837 case msg := <-a.inbox:
838 m = append(m, ant.Content{Type: "text", Text: msg})
839 default:
840 return m, nil
841 }
842 }
843}
844
845func (a *Agent) InnerLoop(ctx context.Context) {
846 // Reset the start of turn time
847 a.startOfTurn = time.Now()
848
849 // Wait for at least one message from the user.
850 msgs, err := a.GatherMessages(ctx, true)
851 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
852 return
853 }
854 // We do this as we go, but let's also do it at the end of the turn
855 defer func() {
856 if _, err := a.handleGitCommits(ctx); err != nil {
857 // Just log the error, don't stop execution
858 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
859 }
860 }()
861
862 userMessage := ant.Message{
863 Role: "user",
864 Content: msgs,
865 }
866 // convo.SendMessage does the actual network call to send this to anthropic. This blocks until the response is ready.
867 // 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?
868 resp, err := a.convo.SendMessage(userMessage)
869 if err != nil {
870 a.pushToOutbox(ctx, errorMessage(err))
871 return
872 }
873 for {
874 // TODO: here and below where we check the budget,
875 // we should review the UX: is it clear what happened?
876 // is it clear how to resume?
877 // should we let the user set a new budget?
878 if err := a.overBudget(ctx); err != nil {
879 return
880 }
881 if resp.StopReason != ant.StopReasonToolUse {
882 break
883 }
884 var results []ant.Content
885 cancelled := false
886 select {
887 case <-ctx.Done():
888 // Don't actually run any of the tools, but rather build a response
889 // for each tool_use message letting the LLM know that user canceled it.
890 results, err = a.convo.ToolResultCancelContents(resp)
891 if err != nil {
892 a.pushToOutbox(ctx, errorMessage(err))
893 }
894 cancelled = true
895 default:
896 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
897 // fall-through, when the user has not canceled the inner loop:
898 results, err = a.convo.ToolResultContents(ctx, resp)
899 if ctx.Err() != nil { // e.g. the user canceled the operation
900 cancelled = true
901 } else if err != nil {
902 a.pushToOutbox(ctx, errorMessage(err))
903 }
904 }
905
906 // Check for git commits. Currently we do this here, after we collect
907 // tool results, since that's when we know commits could have happened.
908 // We could instead do this when the turn ends, but I think it makes sense
909 // to do this as we go.
910 newCommits, err := a.handleGitCommits(ctx)
911 if err != nil {
912 // Just log the error, don't stop execution
913 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
914 }
915 var autoqualityMessages []string
916 if len(newCommits) == 1 {
917 formatted := a.codereview.Autoformat(ctx)
918 if len(formatted) > 0 {
919 msg := fmt.Sprintf(`
920I ran autoformatters and they updated these files:
921
922%s
923
924Please amend your latest git commit with these changes and then continue with what you were doing.`,
925 strings.Join(formatted, "\n"),
926 )[1:]
927 a.pushToOutbox(ctx, AgentMessage{
928 Type: AutoMessageType,
929 Content: msg,
930 Timestamp: time.Now(),
931 })
932 autoqualityMessages = append(autoqualityMessages, msg)
933 }
934 }
935
936 if err := a.overBudget(ctx); err != nil {
937 return
938 }
939
940 // Include, along with the tool results (which must go first for whatever reason),
941 // any messages that the user has sent along while the tool_use was executing concurrently.
942 msgs, err = a.GatherMessages(ctx, false)
943 if err != nil {
944 return
945 }
946 // Inject any auto-generated messages from quality checks.
947 for _, msg := range autoqualityMessages {
948 msgs = append(msgs, ant.Content{Type: "text", Text: msg})
949 }
950 if cancelled {
951 msgs = append(msgs, ant.Content{Type: "text", Text: cancelToolUseMessage})
952 // EndOfTurn is false here so that the client of this agent keeps processing
953 // messages from WaitForMessage() and gets the response from the LLM (usually
954 // something like "okay, I'll wait further instructions", but the user should
955 // be made aware of it regardless).
956 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
957 } else if err := a.convo.OverBudget(); err != nil {
958 budgetMsg := "We've exceeded our budget. Please ask the user to confirm before continuing by ending the turn."
959 msgs = append(msgs, ant.Content{Type: "text", Text: budgetMsg})
960 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
961 }
962 results = append(results, msgs...)
963 resp, err = a.convo.SendMessage(ant.Message{
964 Role: "user",
965 Content: results,
966 })
967 if err != nil {
968 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
969 break
970 }
971 if cancelled {
972 return
973 }
974 }
975}
976
977func (a *Agent) overBudget(ctx context.Context) error {
978 if err := a.convo.OverBudget(); err != nil {
979 m := budgetMessage(err)
980 m.Content = m.Content + "\n\nBudget reset."
981 a.pushToOutbox(ctx, budgetMessage(err))
982 a.convo.ResetBudget(a.originalBudget)
983 return err
984 }
985 return nil
986}
987
988func collectTextContent(msg *ant.MessageResponse) string {
989 // Collect all text content
990 var allText strings.Builder
991 for _, content := range msg.Content {
992 if content.Type == "text" && content.Text != "" {
993 if allText.Len() > 0 {
994 allText.WriteString("\n\n")
995 }
996 allText.WriteString(content.Text)
997 }
998 }
999 return allText.String()
1000}
1001
1002func (a *Agent) TotalUsage() ant.CumulativeUsage {
1003 a.mu.Lock()
1004 defer a.mu.Unlock()
1005 return a.convo.CumulativeUsage()
1006}
1007
1008// WaitForMessageCount returns when the agent has at more than clientMessageCount messages or the context is done.
1009func (a *Agent) WaitForMessageCount(ctx context.Context, greaterThan int) {
1010 for a.MessageCount() <= greaterThan {
1011 a.mu.Lock()
1012 ch := make(chan struct{})
1013 // Deletion happens when we notify.
1014 a.listeners = append(a.listeners, ch)
1015 a.mu.Unlock()
1016
1017 select {
1018 case <-ctx.Done():
1019 return
1020 case <-ch:
1021 continue
1022 }
1023 }
1024}
1025
1026// Diff returns a unified diff of changes made since the agent was instantiated.
1027func (a *Agent) Diff(commit *string) (string, error) {
1028 if a.initialCommit == "" {
1029 return "", fmt.Errorf("no initial commit reference available")
1030 }
1031
1032 // Find the repository root
1033 ctx := context.Background()
1034
1035 // If a specific commit hash is provided, show just that commit's changes
1036 if commit != nil && *commit != "" {
1037 // Validate that the commit looks like a valid git SHA
1038 if !isValidGitSHA(*commit) {
1039 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1040 }
1041
1042 // Get the diff for just this commit
1043 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1044 cmd.Dir = a.repoRoot
1045 output, err := cmd.CombinedOutput()
1046 if err != nil {
1047 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1048 }
1049 return string(output), nil
1050 }
1051
1052 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1053 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1054 cmd.Dir = a.repoRoot
1055 output, err := cmd.CombinedOutput()
1056 if err != nil {
1057 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1058 }
1059
1060 return string(output), nil
1061}
1062
1063// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1064func (a *Agent) InitialCommit() string {
1065 return a.initialCommit
1066}
1067
1068// handleGitCommits() highlights new commits to the user. When running
1069// under docker, new HEADs are pushed to a branch according to the title.
1070func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1071 if a.repoRoot == "" {
1072 return nil, nil
1073 }
1074
1075 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1076 if err != nil {
1077 return nil, err
1078 }
1079 if head == a.lastHEAD {
1080 return nil, nil // nothing to do
1081 }
1082 defer func() {
1083 a.lastHEAD = head
1084 }()
1085
1086 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1087 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1088 // to the last 100 commits.
1089 var commits []*GitCommit
1090
1091 // Get commits since the initial commit
1092 // Format: <hash>\0<subject>\0<body>\0
1093 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1094 // Limit to 100 commits to avoid overwhelming the user
1095 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1096 cmd.Dir = a.repoRoot
1097 output, err := cmd.Output()
1098 if err != nil {
1099 return nil, fmt.Errorf("failed to get git log: %w", err)
1100 }
1101
1102 // Parse git log output and filter out already seen commits
1103 parsedCommits := parseGitLog(string(output))
1104
1105 var headCommit *GitCommit
1106
1107 // Filter out commits we've already seen
1108 for _, commit := range parsedCommits {
1109 if commit.Hash == head {
1110 headCommit = &commit
1111 }
1112
1113 // Skip if we've seen this commit before. If our head has changed, always include that.
1114 if a.seenCommits[commit.Hash] && commit.Hash != head {
1115 continue
1116 }
1117
1118 // Mark this commit as seen
1119 a.seenCommits[commit.Hash] = true
1120
1121 // Add to our list of new commits
1122 commits = append(commits, &commit)
1123 }
1124
1125 if a.gitRemoteAddr != "" {
1126 if headCommit == nil {
1127 // I think this can only happen if we have a bug or if there's a race.
1128 headCommit = &GitCommit{}
1129 headCommit.Hash = head
1130 headCommit.Subject = "unknown"
1131 commits = append(commits, headCommit)
1132 }
1133
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001134 branch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
Earl Lee2e463fb2025-04-17 11:22:22 -07001135
1136 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1137 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1138 // then use push with lease to replace.
1139 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1140 cmd.Dir = a.workingDir
1141 if out, err := cmd.CombinedOutput(); err != nil {
1142 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1143 } else {
1144 headCommit.PushedBranch = branch
1145 }
1146 }
1147
1148 // If we found new commits, create a message
1149 if len(commits) > 0 {
1150 msg := AgentMessage{
1151 Type: CommitMessageType,
1152 Timestamp: time.Now(),
1153 Commits: commits,
1154 }
1155 a.pushToOutbox(ctx, msg)
1156 }
1157 return commits, nil
1158}
1159
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001160func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001161 return strings.Map(func(r rune) rune {
1162 // lowercase
1163 if r >= 'A' && r <= 'Z' {
1164 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001165 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001166 // replace spaces with dashes
1167 if r == ' ' {
1168 return '-'
1169 }
1170 // allow alphanumerics and dashes
1171 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1172 return r
1173 }
1174 return -1
1175 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001176}
1177
1178// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1179// and returns an array of GitCommit structs.
1180func parseGitLog(output string) []GitCommit {
1181 var commits []GitCommit
1182
1183 // No output means no commits
1184 if len(output) == 0 {
1185 return commits
1186 }
1187
1188 // Split by NULL byte
1189 parts := strings.Split(output, "\x00")
1190
1191 // Process in triplets (hash, subject, body)
1192 for i := 0; i < len(parts); i++ {
1193 // Skip empty parts
1194 if parts[i] == "" {
1195 continue
1196 }
1197
1198 // This should be a hash
1199 hash := strings.TrimSpace(parts[i])
1200
1201 // Make sure we have at least a subject part available
1202 if i+1 >= len(parts) {
1203 break // No more parts available
1204 }
1205
1206 // Get the subject
1207 subject := strings.TrimSpace(parts[i+1])
1208
1209 // Get the body if available
1210 body := ""
1211 if i+2 < len(parts) {
1212 body = strings.TrimSpace(parts[i+2])
1213 }
1214
1215 // Skip to the next triplet
1216 i += 2
1217
1218 commits = append(commits, GitCommit{
1219 Hash: hash,
1220 Subject: subject,
1221 Body: body,
1222 })
1223 }
1224
1225 return commits
1226}
1227
1228func repoRoot(ctx context.Context, dir string) (string, error) {
1229 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1230 stderr := new(strings.Builder)
1231 cmd.Stderr = stderr
1232 cmd.Dir = dir
1233 out, err := cmd.Output()
1234 if err != nil {
1235 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1236 }
1237 return strings.TrimSpace(string(out)), nil
1238}
1239
1240func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1241 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1242 stderr := new(strings.Builder)
1243 cmd.Stderr = stderr
1244 cmd.Dir = dir
1245 out, err := cmd.Output()
1246 if err != nil {
1247 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1248 }
1249 // TODO: validate that out is valid hex
1250 return strings.TrimSpace(string(out)), nil
1251}
1252
1253// isValidGitSHA validates if a string looks like a valid git SHA hash.
1254// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1255func isValidGitSHA(sha string) bool {
1256 // Git SHA must be a hexadecimal string with at least 4 characters
1257 if len(sha) < 4 || len(sha) > 40 {
1258 return false
1259 }
1260
1261 // Check if the string only contains hexadecimal characters
1262 for _, char := range sha {
1263 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1264 return false
1265 }
1266 }
1267
1268 return true
1269}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001270
1271// getGitOrigin returns the URL of the git remote 'origin' if it exists
1272func getGitOrigin(ctx context.Context, dir string) string {
1273 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1274 cmd.Dir = dir
1275 stderr := new(strings.Builder)
1276 cmd.Stderr = stderr
1277 out, err := cmd.Output()
1278 if err != nil {
1279 return ""
1280 }
1281 return strings.TrimSpace(string(out))
1282}