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