blob: 0d32420f1297abe7266e33ed1e3b4b51d1288077 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package loop
2
3import (
4 "context"
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07005 _ "embed"
Earl Lee2e463fb2025-04-17 11:22:22 -07006 "encoding/json"
7 "fmt"
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +00008 "io"
Earl Lee2e463fb2025-04-17 11:22:22 -07009 "log/slog"
10 "net/http"
11 "os"
12 "os/exec"
Pokey Rule7a113622025-05-12 10:58:45 +010013 "path/filepath"
Earl Lee2e463fb2025-04-17 11:22:22 -070014 "runtime/debug"
15 "slices"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070016 "strconv"
Earl Lee2e463fb2025-04-17 11:22:22 -070017 "strings"
18 "sync"
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +000019 "text/template"
Earl Lee2e463fb2025-04-17 11:22:22 -070020 "time"
21
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000022 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070023 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000024 "sketch.dev/claudetool/bashkit"
Autoformatter4962f152025-05-06 17:24:20 +000025 "sketch.dev/claudetool/browse"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000026 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydera997be62025-05-07 22:52:46 +000027 "sketch.dev/claudetool/onstart"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070028 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070029 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070030 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070031)
32
33const (
34 userCancelMessage = "user requested agent to stop handling responses"
35)
36
Philip Zeyligerb7c58752025-05-01 10:10:17 -070037type MessageIterator interface {
38 // Next blocks until the next message is available. It may
39 // return nil if the underlying iterator context is done.
40 Next() *AgentMessage
41 Close()
42}
43
Earl Lee2e463fb2025-04-17 11:22:22 -070044type CodingAgent interface {
45 // Init initializes an agent inside a docker container.
46 Init(AgentInit) error
47
48 // Ready returns a channel closed after Init successfully called.
49 Ready() <-chan struct{}
50
51 // URL reports the HTTP URL of this agent.
52 URL() string
53
54 // UserMessage enqueues a message to the agent and returns immediately.
55 UserMessage(ctx context.Context, msg string)
56
Philip Zeyligerb7c58752025-05-01 10:10:17 -070057 // Returns an iterator that finishes when the context is done and
58 // starts with the given message index.
59 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070060
Philip Zeyligereab12de2025-05-14 02:35:53 +000061 // Returns an iterator that notifies of state transitions until the context is done.
62 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
63
Earl Lee2e463fb2025-04-17 11:22:22 -070064 // Loop begins the agent loop returns only when ctx is cancelled.
65 Loop(ctx context.Context)
66
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000067 // BranchPrefix returns the configured branch prefix
68 BranchPrefix() string
69
Sean McCulloughedc88dc2025-04-30 02:55:01 +000070 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070071
72 CancelToolUse(toolUseID string, cause error) error
73
74 // Returns a subset of the agent's message history.
75 Messages(start int, end int) []AgentMessage
76
77 // Returns the current number of messages in the history
78 MessageCount() int
79
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070080 TotalUsage() conversation.CumulativeUsage
81 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070082
Earl Lee2e463fb2025-04-17 11:22:22 -070083 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000084 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070085
86 // Diff returns a unified diff of changes made since the agent was instantiated.
87 // If commit is non-nil, it shows the diff for just that specific commit.
88 Diff(commit *string) (string, error)
89
Philip Zeyliger49edc922025-05-14 09:45:45 -070090 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
91 // starts out as the commit where sketch started, but a user can move it if need
92 // be, for example in the case of a rebase. It is stored as a git tag.
93 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070094
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000095 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
96 // (Typically, this is "sketch-base")
97 SketchGitBaseRef() string
98
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -070099 // Slug returns the slug identifier for this session.
100 Slug() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700101
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000102 // BranchName returns the git branch name for the conversation.
103 BranchName() string
104
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700105 // IncrementRetryNumber increments the retry number for branch naming conflicts.
106 IncrementRetryNumber()
107
Earl Lee2e463fb2025-04-17 11:22:22 -0700108 // OS returns the operating system of the client.
109 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000110
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000111 // SessionID returns the unique session identifier.
112 SessionID() string
113
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000114 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700115 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000116
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000117 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
118 OutstandingLLMCallCount() int
119
120 // OutstandingToolCalls returns the names of outstanding tool calls.
121 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000122 OutsideOS() string
123 OutsideHostname() string
124 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000125 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000126 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
127 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700128
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700129 // IsInContainer returns true if the agent is running in a container
130 IsInContainer() bool
131 // FirstMessageIndex returns the index of the first message in the current conversation
132 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700133
134 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700135 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
136 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700137
138 // CompactConversation compacts the current conversation by generating a summary
139 // and restarting the conversation with that summary as the initial context
140 CompactConversation(ctx context.Context) error
Sean McCullough138ec242025-06-02 22:42:06 +0000141 // GetPortMonitor returns the port monitor instance for accessing port events
142 GetPortMonitor() *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700143}
144
145type CodingAgentMessageType string
146
147const (
148 UserMessageType CodingAgentMessageType = "user"
149 AgentMessageType CodingAgentMessageType = "agent"
150 ErrorMessageType CodingAgentMessageType = "error"
151 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
152 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700153 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
154 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
155 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700156
157 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
158)
159
160type AgentMessage struct {
161 Type CodingAgentMessageType `json:"type"`
162 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
163 EndOfTurn bool `json:"end_of_turn"`
164
165 Content string `json:"content"`
166 ToolName string `json:"tool_name,omitempty"`
167 ToolInput string `json:"input,omitempty"`
168 ToolResult string `json:"tool_result,omitempty"`
169 ToolError bool `json:"tool_error,omitempty"`
170 ToolCallId string `json:"tool_call_id,omitempty"`
171
172 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
173 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
174
Sean McCulloughd9f13372025-04-21 15:08:49 -0700175 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
176 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
177
Earl Lee2e463fb2025-04-17 11:22:22 -0700178 // Commits is a list of git commits for a commit message
179 Commits []*GitCommit `json:"commits,omitempty"`
180
181 Timestamp time.Time `json:"timestamp"`
182 ConversationID string `json:"conversation_id"`
183 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700184 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700185
186 // Message timing information
187 StartTime *time.Time `json:"start_time,omitempty"`
188 EndTime *time.Time `json:"end_time,omitempty"`
189 Elapsed *time.Duration `json:"elapsed,omitempty"`
190
191 // Turn duration - the time taken for a complete agent turn
192 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
193
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000194 // HideOutput indicates that this message should not be rendered in the UI.
195 // This is useful for subconversations that generate output that shouldn't be shown to the user.
196 HideOutput bool `json:"hide_output,omitempty"`
197
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700198 // TodoContent contains the agent's todo file content when it has changed
199 TodoContent *string `json:"todo_content,omitempty"`
200
Earl Lee2e463fb2025-04-17 11:22:22 -0700201 Idx int `json:"idx"`
202}
203
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000204// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700205func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700206 if convo == nil {
207 m.ConversationID = ""
208 m.ParentConversationID = nil
209 return
210 }
211 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000212 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700213 if convo.Parent != nil {
214 m.ParentConversationID = &convo.Parent.ID
215 }
216}
217
Earl Lee2e463fb2025-04-17 11:22:22 -0700218// GitCommit represents a single git commit for a commit message
219type GitCommit struct {
220 Hash string `json:"hash"` // Full commit hash
221 Subject string `json:"subject"` // Commit subject line
222 Body string `json:"body"` // Full commit message body
223 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
224}
225
226// ToolCall represents a single tool call within an agent message
227type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700228 Name string `json:"name"`
229 Input string `json:"input"`
230 ToolCallId string `json:"tool_call_id"`
231 ResultMessage *AgentMessage `json:"result_message,omitempty"`
232 Args string `json:"args,omitempty"`
233 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700234}
235
236func (a *AgentMessage) Attr() slog.Attr {
237 var attrs []any = []any{
238 slog.String("type", string(a.Type)),
239 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700240 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700241 if a.EndOfTurn {
242 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
243 }
244 if a.Content != "" {
245 attrs = append(attrs, slog.String("content", a.Content))
246 }
247 if a.ToolName != "" {
248 attrs = append(attrs, slog.String("tool_name", a.ToolName))
249 }
250 if a.ToolInput != "" {
251 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
252 }
253 if a.Elapsed != nil {
254 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
255 }
256 if a.TurnDuration != nil {
257 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
258 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700259 if len(a.ToolResult) > 0 {
260 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700261 }
262 if a.ToolError {
263 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
264 }
265 if len(a.ToolCalls) > 0 {
266 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
267 for i, tc := range a.ToolCalls {
268 toolCallAttrs = append(toolCallAttrs, slog.Group(
269 fmt.Sprintf("tool_call_%d", i),
270 slog.String("name", tc.Name),
271 slog.String("input", tc.Input),
272 ))
273 }
274 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
275 }
276 if a.ConversationID != "" {
277 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
278 }
279 if a.ParentConversationID != nil {
280 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
281 }
282 if a.Usage != nil && !a.Usage.IsZero() {
283 attrs = append(attrs, a.Usage.Attr())
284 }
285 // TODO: timestamp, convo ids, idx?
286 return slog.Group("agent_message", attrs...)
287}
288
289func errorMessage(err error) AgentMessage {
290 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
291 if os.Getenv(("DEBUG")) == "1" {
292 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
293 }
294
295 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
296}
297
298func budgetMessage(err error) AgentMessage {
299 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
300}
301
302// ConvoInterface defines the interface for conversation interactions
303type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700304 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700305 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700306 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700307 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700308 SendMessage(message llm.Message) (*llm.Response, error)
309 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700310 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000311 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700312 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700313 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700314 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700315}
316
Philip Zeyligerf2872992025-05-22 10:35:28 -0700317// AgentGitState holds the state necessary for pushing to a remote git repo
318// when HEAD changes. If gitRemoteAddr is set, then we push to sketch/
319// any time we notice we need to.
320type AgentGitState struct {
321 mu sync.Mutex // protects following
322 lastHEAD string // hash of the last HEAD that was pushed to the host
323 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000324 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700325 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700326 slug string // Human-readable session identifier
327 retryNumber int // Number to append when branch conflicts occur
Philip Zeyligerf2872992025-05-22 10:35:28 -0700328}
329
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700330func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700331 ags.mu.Lock()
332 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700333 if ags.slug != slug {
334 ags.retryNumber = 0
335 }
336 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700337}
338
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700339func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700340 ags.mu.Lock()
341 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700342 return ags.slug
343}
344
345func (ags *AgentGitState) IncrementRetryNumber() {
346 ags.mu.Lock()
347 defer ags.mu.Unlock()
348 ags.retryNumber++
349}
350
351// HasSeenCommits returns true if any commits have been processed
352func (ags *AgentGitState) HasSeenCommits() bool {
353 ags.mu.Lock()
354 defer ags.mu.Unlock()
355 return len(ags.seenCommits) > 0
356}
357
358func (ags *AgentGitState) RetryNumber() int {
359 ags.mu.Lock()
360 defer ags.mu.Unlock()
361 return ags.retryNumber
362}
363
364func (ags *AgentGitState) BranchName(prefix string) string {
365 ags.mu.Lock()
366 defer ags.mu.Unlock()
367 return ags.branchNameLocked(prefix)
368}
369
370func (ags *AgentGitState) branchNameLocked(prefix string) string {
371 if ags.slug == "" {
372 return ""
373 }
374 if ags.retryNumber == 0 {
375 return prefix + ags.slug
376 }
377 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700378}
379
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000380func (ags *AgentGitState) Upstream() string {
381 ags.mu.Lock()
382 defer ags.mu.Unlock()
383 return ags.upstream
384}
385
Earl Lee2e463fb2025-04-17 11:22:22 -0700386type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700387 convo ConvoInterface
388 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700389 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700390 workingDir string
391 repoRoot string // workingDir may be a subdir of repoRoot
392 url string
393 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000394 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700395 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000396 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700397 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700398 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000399 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700400 // State machine to track agent state
401 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000402 // Outside information
403 outsideHostname string
404 outsideOS string
405 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000406 // URL of the git remote 'origin' if it exists
407 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700408
409 // Time when the current turn started (reset at the beginning of InnerLoop)
410 startOfTurn time.Time
411
412 // Inbox - for messages from the user to the agent.
413 // sent on by UserMessage
414 // . e.g. when user types into the chat textarea
415 // read from by GatherMessages
416 inbox chan string
417
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000418 // protects cancelTurn
419 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700420 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000421 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700422
423 // protects following
424 mu sync.Mutex
425
426 // Stores all messages for this agent
427 history []AgentMessage
428
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700429 // Iterators add themselves here when they're ready to be notified of new messages.
430 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700431
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000432 // Track outstanding LLM call IDs
433 outstandingLLMCalls map[string]struct{}
434
435 // Track outstanding tool calls by ID with their names
436 outstandingToolCalls map[string]string
Sean McCullough364f7412025-06-02 00:55:44 +0000437
438 // Port monitoring
439 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700440}
441
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700442// NewIterator implements CodingAgent.
443func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
444 a.mu.Lock()
445 defer a.mu.Unlock()
446
447 return &MessageIteratorImpl{
448 agent: a,
449 ctx: ctx,
450 nextMessageIdx: nextMessageIdx,
451 ch: make(chan *AgentMessage, 100),
452 }
453}
454
455type MessageIteratorImpl struct {
456 agent *Agent
457 ctx context.Context
458 nextMessageIdx int
459 ch chan *AgentMessage
460 subscribed bool
461}
462
463func (m *MessageIteratorImpl) Close() {
464 m.agent.mu.Lock()
465 defer m.agent.mu.Unlock()
466 // Delete ourselves from the subscribers list
467 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
468 return x == m.ch
469 })
470 close(m.ch)
471}
472
473func (m *MessageIteratorImpl) Next() *AgentMessage {
474 // We avoid subscription at creation to let ourselves catch up to "current state"
475 // before subscribing.
476 if !m.subscribed {
477 m.agent.mu.Lock()
478 if m.nextMessageIdx < len(m.agent.history) {
479 msg := &m.agent.history[m.nextMessageIdx]
480 m.nextMessageIdx++
481 m.agent.mu.Unlock()
482 return msg
483 }
484 // The next message doesn't exist yet, so let's subscribe
485 m.agent.subscribers = append(m.agent.subscribers, m.ch)
486 m.subscribed = true
487 m.agent.mu.Unlock()
488 }
489
490 for {
491 select {
492 case <-m.ctx.Done():
493 m.agent.mu.Lock()
494 // Delete ourselves from the subscribers list
495 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
496 return x == m.ch
497 })
498 m.subscribed = false
499 m.agent.mu.Unlock()
500 return nil
501 case msg, ok := <-m.ch:
502 if !ok {
503 // Close may have been called
504 return nil
505 }
506 if msg.Idx == m.nextMessageIdx {
507 m.nextMessageIdx++
508 return msg
509 }
510 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
511 panic("out of order message")
512 }
513 }
514}
515
Sean McCulloughd9d45812025-04-30 16:53:41 -0700516// Assert that Agent satisfies the CodingAgent interface.
517var _ CodingAgent = &Agent{}
518
519// StateName implements CodingAgent.
520func (a *Agent) CurrentStateName() string {
521 if a.stateMachine == nil {
522 return ""
523 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000524 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700525}
526
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700527// CurrentTodoContent returns the current todo list data as JSON.
528// It returns an empty string if no todos exist.
529func (a *Agent) CurrentTodoContent() string {
530 todoPath := claudetool.TodoFilePath(a.config.SessionID)
531 content, err := os.ReadFile(todoPath)
532 if err != nil {
533 return ""
534 }
535 return string(content)
536}
537
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700538// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
539func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
540 msg := `You are being asked to create a comprehensive summary of our conversation so far. This summary will be used to restart our conversation with a shorter history while preserving all important context.
541
542IMPORTANT: Focus ONLY on the actual conversation with the user. Do NOT include any information from system prompts, tool descriptions, or general instructions. Only summarize what the user asked for and what we accomplished together.
543
544Please create a detailed summary that includes:
545
5461. **User's Request**: What did the user originally ask me to do? What was their goal?
547
5482. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
549
5503. **Key Technical Decisions**: What important technical choices were made during our work and why?
551
5524. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
553
5545. **Next Steps**: What still needs to be done to complete the user's request?
555
5566. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
557
558Focus on actionable information that would help me continue the user's work seamlessly. Ignore any general tool capabilities or system instructions - only include what's relevant to this specific user's project and goals.
559
560Reply with ONLY the summary content - no meta-commentary about creating the summary.`
561
562 userMessage := llm.UserStringMessage(msg)
563 // Use a subconversation with history to get the summary
564 // TODO: We don't have any tools here, so we should have enough tokens
565 // to capture a summary, but we may need to modify the history (e.g., remove
566 // TODO data) to save on some tokens.
567 convo := a.convo.SubConvoWithHistory()
568
569 // Modify the system prompt to provide context about the original task
570 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000571 convo.SystemPrompt = `You are creating a conversation summary for context compaction. The original system prompt contained instructions about being a software engineer and architect for Sketch (an agentic coding environment), with various tools and capabilities for code analysis, file modification, git operations, browser automation, and project management.
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700572
573Your task is to create a focused summary as requested below. Focus only on the actual user conversation and work accomplished, not the system capabilities or tool descriptions.
574
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000575Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700576
577 resp, err := convo.SendMessage(userMessage)
578 if err != nil {
579 a.pushToOutbox(ctx, errorMessage(err))
580 return "", err
581 }
582 textContent := collectTextContent(resp)
583
584 // Restore original system prompt (though this subconvo will be discarded)
585 convo.SystemPrompt = originalSystemPrompt
586
587 return textContent, nil
588}
589
590// CompactConversation compacts the current conversation by generating a summary
591// and restarting the conversation with that summary as the initial context
592func (a *Agent) CompactConversation(ctx context.Context) error {
593 summary, err := a.generateConversationSummary(ctx)
594 if err != nil {
595 return fmt.Errorf("failed to generate conversation summary: %w", err)
596 }
597
598 a.mu.Lock()
599
600 // Get usage information before resetting conversation
601 lastUsage := a.convo.LastUsage()
602 contextWindow := a.config.Service.TokenContextWindow()
603 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
604
605 // Reset conversation state but keep all other state (git, working dir, etc.)
606 a.firstMessageIndex = len(a.history)
607 a.convo = a.initConvo()
608
609 a.mu.Unlock()
610
611 // Create informative compaction message with token details
612 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
613 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
614 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
615
616 a.pushToOutbox(ctx, AgentMessage{
617 Type: CompactMessageType,
618 Content: compactionMsg,
619 })
620
621 a.pushToOutbox(ctx, AgentMessage{
622 Type: UserMessageType,
623 Content: fmt.Sprintf("Here's a summary of our previous work:\n\n%s\n\nPlease continue with the work based on this summary.", summary),
624 })
625 a.inbox <- fmt.Sprintf("Here's a summary of our previous work:\n\n%s\n\nPlease continue with the work based on this summary.", summary)
626
627 return nil
628}
629
Earl Lee2e463fb2025-04-17 11:22:22 -0700630func (a *Agent) URL() string { return a.url }
631
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000632// BranchName returns the git branch name for the conversation.
633func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700634 return a.gitState.BranchName(a.config.BranchPrefix)
635}
636
637// Slug returns the slug identifier for this conversation.
638func (a *Agent) Slug() string {
639 return a.gitState.Slug()
640}
641
642// IncrementRetryNumber increments the retry number for branch naming conflicts
643func (a *Agent) IncrementRetryNumber() {
644 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000645}
646
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000647// OutstandingLLMCallCount returns the number of outstanding LLM calls.
648func (a *Agent) OutstandingLLMCallCount() int {
649 a.mu.Lock()
650 defer a.mu.Unlock()
651 return len(a.outstandingLLMCalls)
652}
653
654// OutstandingToolCalls returns the names of outstanding tool calls.
655func (a *Agent) OutstandingToolCalls() []string {
656 a.mu.Lock()
657 defer a.mu.Unlock()
658
659 tools := make([]string, 0, len(a.outstandingToolCalls))
660 for _, toolName := range a.outstandingToolCalls {
661 tools = append(tools, toolName)
662 }
663 return tools
664}
665
Earl Lee2e463fb2025-04-17 11:22:22 -0700666// OS returns the operating system of the client.
667func (a *Agent) OS() string {
668 return a.config.ClientGOOS
669}
670
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000671func (a *Agent) SessionID() string {
672 return a.config.SessionID
673}
674
Philip Zeyliger18532b22025-04-23 21:11:46 +0000675// OutsideOS returns the operating system of the outside system.
676func (a *Agent) OutsideOS() string {
677 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000678}
679
Philip Zeyliger18532b22025-04-23 21:11:46 +0000680// OutsideHostname returns the hostname of the outside system.
681func (a *Agent) OutsideHostname() string {
682 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000683}
684
Philip Zeyliger18532b22025-04-23 21:11:46 +0000685// OutsideWorkingDir returns the working directory on the outside system.
686func (a *Agent) OutsideWorkingDir() string {
687 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000688}
689
690// GitOrigin returns the URL of the git remote 'origin' if it exists.
691func (a *Agent) GitOrigin() string {
692 return a.gitOrigin
693}
694
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000695func (a *Agent) OpenBrowser(url string) {
696 if !a.IsInContainer() {
697 browser.Open(url)
698 return
699 }
700 // We're in Docker, need to send a request to the Git server
701 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700702 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000703 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700704 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000705 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700706 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000707 return
708 }
709 defer resp.Body.Close()
710 if resp.StatusCode == http.StatusOK {
711 return
712 }
713 body, _ := io.ReadAll(resp.Body)
714 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
715}
716
Sean McCullough96b60dd2025-04-30 09:49:10 -0700717// CurrentState returns the current state of the agent's state machine.
718func (a *Agent) CurrentState() State {
719 return a.stateMachine.CurrentState()
720}
721
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700722func (a *Agent) IsInContainer() bool {
723 return a.config.InDocker
724}
725
726func (a *Agent) FirstMessageIndex() int {
727 a.mu.Lock()
728 defer a.mu.Unlock()
729 return a.firstMessageIndex
730}
731
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700732// SetSlug sets a human-readable identifier for the conversation.
733func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700734 a.mu.Lock()
735 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700736
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700737 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000738 convo, ok := a.convo.(*conversation.Convo)
739 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700740 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000741 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700742}
743
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000744// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700745func (a *Agent) OnToolCall(ctx context.Context, convo *conversation.Convo, id string, toolName string, toolInput json.RawMessage, content llm.Content) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000746 // Track the tool call
747 a.mu.Lock()
748 a.outstandingToolCalls[id] = toolName
749 a.mu.Unlock()
750}
751
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700752// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
753// If there's only one element in the array and it's a text type, it returns that text directly.
754// It also processes nested ToolResult arrays recursively.
755func contentToString(contents []llm.Content) string {
756 if len(contents) == 0 {
757 return ""
758 }
759
760 // If there's only one element and it's a text type, return it directly
761 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
762 return contents[0].Text
763 }
764
765 // Otherwise, concatenate all text content
766 var result strings.Builder
767 for _, content := range contents {
768 if content.Type == llm.ContentTypeText {
769 result.WriteString(content.Text)
770 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
771 // Recursively process nested tool results
772 result.WriteString(contentToString(content.ToolResult))
773 }
774 }
775
776 return result.String()
777}
778
Earl Lee2e463fb2025-04-17 11:22:22 -0700779// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700780func (a *Agent) OnToolResult(ctx context.Context, convo *conversation.Convo, toolID string, toolName string, toolInput json.RawMessage, content llm.Content, result *string, err error) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000781 // Remove the tool call from outstanding calls
782 a.mu.Lock()
783 delete(a.outstandingToolCalls, toolID)
784 a.mu.Unlock()
785
Earl Lee2e463fb2025-04-17 11:22:22 -0700786 m := AgentMessage{
787 Type: ToolUseMessageType,
788 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700789 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700790 ToolError: content.ToolError,
791 ToolName: toolName,
792 ToolInput: string(toolInput),
793 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700794 StartTime: content.ToolUseStartTime,
795 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700796 }
797
798 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700799 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
800 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700801 m.Elapsed = &elapsed
802 }
803
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700804 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700805 a.pushToOutbox(ctx, m)
806}
807
808// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700809func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000810 a.mu.Lock()
811 defer a.mu.Unlock()
812 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700813 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
814}
815
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700816// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700817// that need to be displayed (as well as tool calls that we send along when
818// they're done). (It would be reasonable to also mention tool calls when they're
819// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700820func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000821 // Remove the LLM call from outstanding calls
822 a.mu.Lock()
823 delete(a.outstandingLLMCalls, id)
824 a.mu.Unlock()
825
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700826 if resp == nil {
827 // LLM API call failed
828 m := AgentMessage{
829 Type: ErrorMessageType,
830 Content: "API call failed, type 'continue' to try again",
831 }
832 m.SetConvo(convo)
833 a.pushToOutbox(ctx, m)
834 return
835 }
836
Earl Lee2e463fb2025-04-17 11:22:22 -0700837 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700838 if convo.Parent == nil { // subconvos never end the turn
839 switch resp.StopReason {
840 case llm.StopReasonToolUse:
841 // Check whether any of the tool calls are for tools that should end the turn
842 ToolSearch:
843 for _, part := range resp.Content {
844 if part.Type != llm.ContentTypeToolUse {
845 continue
846 }
Sean McCullough021557a2025-05-05 23:20:53 +0000847 // Find the tool by name
848 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700849 if tool.Name == part.ToolName {
850 endOfTurn = tool.EndsTurn
851 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000852 }
853 }
Sean McCullough021557a2025-05-05 23:20:53 +0000854 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700855 default:
856 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000857 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700858 }
859 m := AgentMessage{
860 Type: AgentMessageType,
861 Content: collectTextContent(resp),
862 EndOfTurn: endOfTurn,
863 Usage: &resp.Usage,
864 StartTime: resp.StartTime,
865 EndTime: resp.EndTime,
866 }
867
868 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700869 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700870 var toolCalls []ToolCall
871 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700872 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700873 toolCalls = append(toolCalls, ToolCall{
874 Name: part.ToolName,
875 Input: string(part.ToolInput),
876 ToolCallId: part.ID,
877 })
878 }
879 }
880 m.ToolCalls = toolCalls
881 }
882
883 // Calculate the elapsed time if both start and end times are set
884 if resp.StartTime != nil && resp.EndTime != nil {
885 elapsed := resp.EndTime.Sub(*resp.StartTime)
886 m.Elapsed = &elapsed
887 }
888
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700889 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700890 a.pushToOutbox(ctx, m)
891}
892
893// WorkingDir implements CodingAgent.
894func (a *Agent) WorkingDir() string {
895 return a.workingDir
896}
897
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000898// RepoRoot returns the git repository root directory.
899func (a *Agent) RepoRoot() string {
900 return a.repoRoot
901}
902
Earl Lee2e463fb2025-04-17 11:22:22 -0700903// MessageCount implements CodingAgent.
904func (a *Agent) MessageCount() int {
905 a.mu.Lock()
906 defer a.mu.Unlock()
907 return len(a.history)
908}
909
910// Messages implements CodingAgent.
911func (a *Agent) Messages(start int, end int) []AgentMessage {
912 a.mu.Lock()
913 defer a.mu.Unlock()
914 return slices.Clone(a.history[start:end])
915}
916
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700917// ShouldCompact checks if the conversation should be compacted based on token usage
918func (a *Agent) ShouldCompact() bool {
919 // Get the threshold from environment variable, default to 0.94 (94%)
920 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
921 // and a little bit of buffer.)
922 thresholdRatio := 0.94
923 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
924 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
925 thresholdRatio = parsed
926 }
927 }
928
929 // Get the most recent usage to check current context size
930 lastUsage := a.convo.LastUsage()
931
932 if lastUsage.InputTokens == 0 {
933 // No API calls made yet
934 return false
935 }
936
937 // Calculate the current context size from the last API call
938 // This includes all tokens that were part of the input context:
939 // - Input tokens (user messages, system prompt, conversation history)
940 // - Cache read tokens (cached parts of the context)
941 // - Cache creation tokens (new parts being cached)
942 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
943
944 // Get the service's token context window
945 service := a.config.Service
946 contextWindow := service.TokenContextWindow()
947
948 // Calculate threshold
949 threshold := uint64(float64(contextWindow) * thresholdRatio)
950
951 // Check if we've exceeded the threshold
952 return currentContextSize >= threshold
953}
954
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700955func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700956 return a.originalBudget
957}
958
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000959// Upstream returns the upstream branch for git work
960func (a *Agent) Upstream() string {
961 return a.gitState.Upstream()
962}
963
Earl Lee2e463fb2025-04-17 11:22:22 -0700964// AgentConfig contains configuration for creating a new Agent.
965type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +0000966 Context context.Context
967 Service llm.Service
968 Budget conversation.Budget
969 GitUsername string
970 GitEmail string
971 SessionID string
972 ClientGOOS string
973 ClientGOARCH string
974 InDocker bool
975 OneShot bool
976 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000977 // Outside information
978 OutsideHostname string
979 OutsideOS string
980 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700981
982 // Outtie's HTTP to, e.g., open a browser
983 OutsideHTTP string
984 // Outtie's Git server
985 GitRemoteAddr string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000986 // Upstream branch for git work
987 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700988 // Commit to checkout from Outtie
989 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000990 // Prefix for git branches created by sketch
991 BranchPrefix string
Earl Lee2e463fb2025-04-17 11:22:22 -0700992}
993
994// NewAgent creates a new Agent.
995// It is not usable until Init() is called.
996func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000997 // Set default branch prefix if not specified
998 if config.BranchPrefix == "" {
999 config.BranchPrefix = "sketch/"
1000 }
1001
Earl Lee2e463fb2025-04-17 11:22:22 -07001002 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001003 config: config,
1004 ready: make(chan struct{}),
1005 inbox: make(chan string, 100),
1006 subscribers: make([]chan *AgentMessage, 0),
1007 startedAt: time.Now(),
1008 originalBudget: config.Budget,
1009 gitState: AgentGitState{
1010 seenCommits: make(map[string]bool),
1011 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001012 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001013 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001014 outsideHostname: config.OutsideHostname,
1015 outsideOS: config.OutsideOS,
1016 outsideWorkingDir: config.OutsideWorkingDir,
1017 outstandingLLMCalls: make(map[string]struct{}),
1018 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001019 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001020 workingDir: config.WorkingDir,
1021 outsideHTTP: config.OutsideHTTP,
Sean McCullough364f7412025-06-02 00:55:44 +00001022 portMonitor: NewPortMonitor(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001023 }
1024 return agent
1025}
1026
1027type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001028 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001029
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001030 InDocker bool
1031 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001032}
1033
1034func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001035 if a.convo != nil {
1036 return fmt.Errorf("Agent.Init: already initialized")
1037 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001038 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001039 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001040
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001041 if !ini.NoGit {
1042 // Capture the original origin before we potentially replace it below
1043 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
1044 }
1045
Philip Zeyliger222bf412025-06-04 16:42:58 +00001046 // If a remote git addr was specified, we configure the origin remote
Philip Zeyligerf2872992025-05-22 10:35:28 -07001047 if a.gitState.gitRemoteAddr != "" {
1048 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
Philip Zeyliger222bf412025-06-04 16:42:58 +00001049
1050 // Remove existing origin remote if it exists
1051 cmd := exec.CommandContext(ctx, "git", "remote", "remove", "origin")
Philip Zeyligerf2872992025-05-22 10:35:28 -07001052 cmd.Dir = a.workingDir
1053 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001054 // Ignore error if origin doesn't exist
1055 slog.DebugContext(ctx, "git remote remove origin (ignoring if not exists)", slog.String("output", string(out)))
Philip Zeyligerf2872992025-05-22 10:35:28 -07001056 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001057
1058 // Add the new remote as origin
1059 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", a.gitState.gitRemoteAddr)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001060 cmd.Dir = a.workingDir
1061 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001062 return fmt.Errorf("git remote add origin: %s: %v", out, err)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001063 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001064
Philip Zeyligerf2872992025-05-22 10:35:28 -07001065 }
1066
1067 // If a commit was specified, we fetch and reset to it.
1068 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001069 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1070
Earl Lee2e463fb2025-04-17 11:22:22 -07001071 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001072 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001073 if out, err := cmd.CombinedOutput(); err != nil {
1074 return fmt.Errorf("git stash: %s: %v", out, err)
1075 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001076 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001077 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001078 if out, err := cmd.CombinedOutput(); err != nil {
1079 return fmt.Errorf("git fetch: %s: %w", out, err)
1080 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001081 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1082 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001083 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1084 // Remove git hooks if they exist and retry
1085 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001086 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001087 if _, statErr := os.Stat(hookPath); statErr == nil {
1088 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1089 slog.String("error", err.Error()),
1090 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001091 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001092 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1093 }
1094
1095 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001096 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1097 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001098 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001099 return fmt.Errorf("git checkout %s failed even after removing hooks: %s: %w", a.config.Commit, retryOut, retryErr)
Pokey Rule7a113622025-05-12 10:58:45 +01001100 }
1101 } else {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001102 return fmt.Errorf("git checkout %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001103 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001104 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001105 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001106
1107 if ini.HostAddr != "" {
1108 a.url = "http://" + ini.HostAddr
1109 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001110
1111 if !ini.NoGit {
1112 repoRoot, err := repoRoot(ctx, a.workingDir)
1113 if err != nil {
1114 return fmt.Errorf("repoRoot: %w", err)
1115 }
1116 a.repoRoot = repoRoot
1117
Earl Lee2e463fb2025-04-17 11:22:22 -07001118 if err != nil {
1119 return fmt.Errorf("resolveRef: %w", err)
1120 }
Philip Zeyliger49edc922025-05-14 09:45:45 -07001121
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001122 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001123 if err := setupGitHooks(a.repoRoot); err != nil {
1124 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1125 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001126 }
1127
Philip Zeyliger49edc922025-05-14 09:45:45 -07001128 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1129 cmd.Dir = repoRoot
1130 if out, err := cmd.CombinedOutput(); err != nil {
1131 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1132 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001133
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001134 slog.Info("running codebase analysis")
1135 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1136 if err != nil {
1137 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001138 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001139 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001140
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001141 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001142 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001143 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001144 }
1145 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001146
Earl Lee2e463fb2025-04-17 11:22:22 -07001147 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001148 a.gitState.lastHEAD = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001149 a.convo = a.initConvo()
1150 close(a.ready)
1151 return nil
1152}
1153
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001154//go:embed agent_system_prompt.txt
1155var agentSystemPrompt string
1156
Earl Lee2e463fb2025-04-17 11:22:22 -07001157// initConvo initializes the conversation.
1158// It must not be called until all agent fields are initialized,
1159// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001160func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001161 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001162 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -07001163 convo.PromptCaching = true
1164 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001165 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001166 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001167
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001168 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1169 bashPermissionCheck := func(command string) error {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001170 if a.gitState.Slug() != "" {
1171 return nil // branch is set up
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001172 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001173 willCommit, err := bashkit.WillRunGitCommit(command)
1174 if err != nil {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001175 return nil // fail open
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001176 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001177 if willCommit {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001178 return fmt.Errorf("you must use the set-slug tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001179 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001180 return nil
1181 }
1182
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001183 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001184
Earl Lee2e463fb2025-04-17 11:22:22 -07001185 // Register all tools with the conversation
1186 // When adding, removing, or modifying tools here, double-check that the termui tool display
1187 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001188
1189 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001190 _, supportsScreenshots := a.config.Service.(*ant.Service)
1191 var bTools []*llm.Tool
1192 var browserCleanup func()
1193
1194 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1195 // Add cleanup function to context cancel
1196 go func() {
1197 <-a.config.Context.Done()
1198 browserCleanup()
1199 }()
1200 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001201
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001202 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001203 bashTool, claudetool.Keyword, claudetool.Patch,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001204 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.setSlugTool(), a.commitMessageStyleTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001205 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001206 }
1207
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001208 // One-shot mode is non-interactive, multiple choice requires human response
1209 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001210 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001211 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001212
1213 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -07001214 convo.Listener = a
1215 return convo
1216}
1217
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001218var multipleChoiceTool = &llm.Tool{
1219 Name: "multiplechoice",
1220 Description: "Present the user with an quick way to answer to your question using one of a short list of possible answers you would expect from the user.",
1221 EndsTurn: true,
1222 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001223 "type": "object",
1224 "description": "The question and a list of answers you would expect the user to choose from.",
1225 "properties": {
1226 "question": {
1227 "type": "string",
1228 "description": "The text of the multiple-choice question you would like the user, e.g. 'What kinds of test cases would you like me to add?'"
1229 },
1230 "responseOptions": {
1231 "type": "array",
1232 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1233 "items": {
1234 "type": "object",
1235 "properties": {
1236 "caption": {
1237 "type": "string",
1238 "description": "The caption, e.g. 'Basic coverage', 'Error return values', or 'Malformed input' for the response button. Do NOT include options for responses that would end the conversation like 'Ok', 'No thank you', 'This looks good'"
1239 },
1240 "responseText": {
1241 "type": "string",
1242 "description": "The full text of the response, e.g. 'Add unit tests for basic test coverage', 'Add unit tests for error return values', or 'Add unit tests for malformed input'"
1243 }
1244 },
1245 "required": ["caption", "responseText"]
1246 }
1247 }
1248 },
1249 "required": ["question", "responseOptions"]
1250}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001251 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1252 // The Run logic for "multiplechoice" tool is a no-op on the server.
1253 // The UI will present a list of options for the user to select from,
1254 // and that's it as far as "executing" the tool_use goes.
1255 // When the user *does* select one of the presented options, that
1256 // responseText gets sent as a chat message on behalf of the user.
1257 return llm.TextContent("end your turn and wait for the user to respond"), nil
1258 },
Sean McCullough485afc62025-04-28 14:28:39 -07001259}
1260
1261type MultipleChoiceOption struct {
1262 Caption string `json:"caption"`
1263 ResponseText string `json:"responseText"`
1264}
1265
1266type MultipleChoiceParams struct {
1267 Question string `json:"question"`
1268 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1269}
1270
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001271// branchExists reports whether branchName exists, either locally or in well-known remotes.
1272func branchExists(dir, branchName string) bool {
1273 refs := []string{
1274 "refs/heads/",
1275 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001276 }
1277 for _, ref := range refs {
1278 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1279 cmd.Dir = dir
1280 if cmd.Run() == nil { // exit code 0 means branch exists
1281 return true
1282 }
1283 }
1284 return false
1285}
1286
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001287func (a *Agent) setSlugTool() *llm.Tool {
1288 return &llm.Tool{
1289 Name: "set-slug",
1290 Description: `Set a short slug as an identifier for this conversation.`,
Earl Lee2e463fb2025-04-17 11:22:22 -07001291 InputSchema: json.RawMessage(`{
1292 "type": "object",
1293 "properties": {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001294 "slug": {
Earl Lee2e463fb2025-04-17 11:22:22 -07001295 "type": "string",
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001296 "description": "A 2-3 word alphanumeric hyphenated slug, imperative tense"
Earl Lee2e463fb2025-04-17 11:22:22 -07001297 }
1298 },
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001299 "required": ["slug"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001300}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001301 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001302 var params struct {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001303 Slug string `json:"slug"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001304 }
1305 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001306 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001307 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001308 // Prevent slug changes if there have been git changes
1309 // This lets the agent change its mind about a good slug,
1310 // while ensuring that once a branch has been pushed, it remains stable.
1311 if s := a.Slug(); s != "" && s != params.Slug && a.gitState.HasSeenCommits() {
1312 return nil, fmt.Errorf("slug already set to %q", s)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001313 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001314 if params.Slug == "" {
1315 return nil, fmt.Errorf("slug parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001316 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001317 slug := cleanSlugName(params.Slug)
1318 if slug == "" {
1319 return nil, fmt.Errorf("slug parameter could not be converted to a valid slug")
1320 }
1321 a.SetSlug(slug)
1322 // TODO: do this by a call to outie, rather than semi-guessing from innie
1323 if branchExists(a.workingDir, a.BranchName()) {
1324 return nil, fmt.Errorf("slug %q already exists; please choose a different slug", slug)
1325 }
1326 return llm.TextContent("OK"), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001327 },
1328 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001329}
1330
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001331func (a *Agent) commitMessageStyleTool() *llm.Tool {
1332 description := `Provides git commit message style guidance. MANDATORY: You must use this tool before making any git commits.`
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001333 preCommit := &llm.Tool{
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001334 Name: "commit-message-style",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001335 Description: description,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001336 InputSchema: llm.EmptySchema(),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001337 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001338 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1339 if err != nil {
1340 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1341 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001342 return llm.TextContent(styleHint), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001343 },
1344 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001345 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001346}
1347
1348func (a *Agent) Ready() <-chan struct{} {
1349 return a.ready
1350}
1351
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001352// BranchPrefix returns the configured branch prefix
1353func (a *Agent) BranchPrefix() string {
1354 return a.config.BranchPrefix
1355}
1356
Earl Lee2e463fb2025-04-17 11:22:22 -07001357func (a *Agent) UserMessage(ctx context.Context, msg string) {
1358 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1359 a.inbox <- msg
1360}
1361
Earl Lee2e463fb2025-04-17 11:22:22 -07001362func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1363 return a.convo.CancelToolUse(toolUseID, cause)
1364}
1365
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001366func (a *Agent) CancelTurn(cause error) {
1367 a.cancelTurnMu.Lock()
1368 defer a.cancelTurnMu.Unlock()
1369 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001370 // Force state transition to cancelled state
1371 ctx := a.config.Context
1372 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001373 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001374 }
1375}
1376
1377func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001378 // Start port monitoring when the agent loop begins
1379 // Only monitor ports when running in a container
1380 if a.IsInContainer() {
1381 a.portMonitor.Start(ctxOuter)
1382 }
1383
Earl Lee2e463fb2025-04-17 11:22:22 -07001384 for {
1385 select {
1386 case <-ctxOuter.Done():
1387 return
1388 default:
1389 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001390 a.cancelTurnMu.Lock()
1391 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001392 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001393 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001394 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001395 a.cancelTurn = cancel
1396 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001397 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1398 if err != nil {
1399 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1400 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001401 cancel(nil)
1402 }
1403 }
1404}
1405
1406func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1407 if m.Timestamp.IsZero() {
1408 m.Timestamp = time.Now()
1409 }
1410
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001411 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1412 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1413 m.Content = m.ToolResult
1414 }
1415
Earl Lee2e463fb2025-04-17 11:22:22 -07001416 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1417 if m.EndOfTurn && m.Type == AgentMessageType {
1418 turnDuration := time.Since(a.startOfTurn)
1419 m.TurnDuration = &turnDuration
1420 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1421 }
1422
Earl Lee2e463fb2025-04-17 11:22:22 -07001423 a.mu.Lock()
1424 defer a.mu.Unlock()
1425 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001426 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001427 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001428
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001429 // Notify all subscribers
1430 for _, ch := range a.subscribers {
1431 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001432 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001433}
1434
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001435func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1436 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001437 if block {
1438 select {
1439 case <-ctx.Done():
1440 return m, ctx.Err()
1441 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001442 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001443 }
1444 }
1445 for {
1446 select {
1447 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001448 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001449 default:
1450 return m, nil
1451 }
1452 }
1453}
1454
Sean McCullough885a16a2025-04-30 02:49:25 +00001455// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001456func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001457 // Reset the start of turn time
1458 a.startOfTurn = time.Now()
1459
Sean McCullough96b60dd2025-04-30 09:49:10 -07001460 // Transition to waiting for user input state
1461 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1462
Sean McCullough885a16a2025-04-30 02:49:25 +00001463 // Process initial user message
1464 initialResp, err := a.processUserMessage(ctx)
1465 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001466 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001467 return err
1468 }
1469
1470 // Handle edge case where both initialResp and err are nil
1471 if initialResp == nil {
1472 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001473 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1474
Sean McCullough9f4b8082025-04-30 17:34:07 +00001475 a.pushToOutbox(ctx, errorMessage(err))
1476 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001477 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001478
Earl Lee2e463fb2025-04-17 11:22:22 -07001479 // We do this as we go, but let's also do it at the end of the turn
1480 defer func() {
1481 if _, err := a.handleGitCommits(ctx); err != nil {
1482 // Just log the error, don't stop execution
1483 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1484 }
1485 }()
1486
Sean McCullougha1e0e492025-05-01 10:51:08 -07001487 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001488 resp := initialResp
1489 for {
1490 // Check if we are over budget
1491 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001492 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001493 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001494 }
1495
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001496 // Check if we should compact the conversation
1497 if a.ShouldCompact() {
1498 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1499 if err := a.CompactConversation(ctx); err != nil {
1500 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1501 return err
1502 }
1503 // After compaction, end this turn and start fresh
1504 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1505 return nil
1506 }
1507
Sean McCullough885a16a2025-04-30 02:49:25 +00001508 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001509 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001510 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001511 break
1512 }
1513
Sean McCullough96b60dd2025-04-30 09:49:10 -07001514 // Transition to tool use requested state
1515 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1516
Sean McCullough885a16a2025-04-30 02:49:25 +00001517 // Handle tool execution
1518 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1519 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001520 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001521 }
1522
Sean McCullougha1e0e492025-05-01 10:51:08 -07001523 if toolResp == nil {
1524 return fmt.Errorf("cannot continue conversation with a nil tool response")
1525 }
1526
Sean McCullough885a16a2025-04-30 02:49:25 +00001527 // Set the response for the next iteration
1528 resp = toolResp
1529 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001530
1531 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001532}
1533
1534// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001535func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001536 // Wait for at least one message from the user
1537 msgs, err := a.GatherMessages(ctx, true)
1538 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001539 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001540 return nil, err
1541 }
1542
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001543 userMessage := llm.Message{
1544 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001545 Content: msgs,
1546 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001547
Sean McCullough96b60dd2025-04-30 09:49:10 -07001548 // Transition to sending to LLM state
1549 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1550
Sean McCullough885a16a2025-04-30 02:49:25 +00001551 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001552 resp, err := a.convo.SendMessage(userMessage)
1553 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001554 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001555 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001556 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001557 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001558
Sean McCullough96b60dd2025-04-30 09:49:10 -07001559 // Transition to processing LLM response state
1560 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1561
Sean McCullough885a16a2025-04-30 02:49:25 +00001562 return resp, nil
1563}
1564
1565// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001566func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1567 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001568 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001569 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001570
Sean McCullough96b60dd2025-04-30 09:49:10 -07001571 // Transition to checking for cancellation state
1572 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1573
Sean McCullough885a16a2025-04-30 02:49:25 +00001574 // Check if the operation was cancelled by the user
1575 select {
1576 case <-ctx.Done():
1577 // Don't actually run any of the tools, but rather build a response
1578 // for each tool_use message letting the LLM know that user canceled it.
1579 var err error
1580 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001581 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001582 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001583 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001584 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001585 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001586 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001587 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001588 // Transition to running tool state
1589 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1590
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001591 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001592 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001593 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001594
1595 // Execute the tools
1596 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001597 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001598 if ctx.Err() != nil { // e.g. the user canceled the operation
1599 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001600 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001601 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001602 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001603 a.pushToOutbox(ctx, errorMessage(err))
1604 }
1605 }
1606
1607 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001608 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001609 autoqualityMessages := a.processGitChanges(ctx)
1610
1611 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001612 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001613 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001614 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001615 return false, nil
1616 }
1617
1618 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001619 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1620 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001621}
1622
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001623// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001624func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001625 // Check for git commits
1626 _, err := a.handleGitCommits(ctx)
1627 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001628 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001629 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001630 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001631 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001632}
1633
1634// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1635// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001636func (a *Agent) processGitChanges(ctx context.Context) []string {
1637 // Check for git commits after tool execution
1638 newCommits, err := a.handleGitCommits(ctx)
1639 if err != nil {
1640 // Just log the error, don't stop execution
1641 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1642 return nil
1643 }
1644
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001645 // Run mechanical checks if there was exactly one new commit.
1646 if len(newCommits) != 1 {
1647 return nil
1648 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001649 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001650 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1651 msg := a.codereview.RunMechanicalChecks(ctx)
1652 if msg != "" {
1653 a.pushToOutbox(ctx, AgentMessage{
1654 Type: AutoMessageType,
1655 Content: msg,
1656 Timestamp: time.Now(),
1657 })
1658 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001659 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001660
1661 return autoqualityMessages
1662}
1663
1664// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001665func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001666 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001667 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001668 msgs, err := a.GatherMessages(ctx, false)
1669 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001670 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001671 return false, nil
1672 }
1673
1674 // Inject any auto-generated messages from quality checks
1675 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001676 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001677 }
1678
1679 // Handle cancellation by appending a message about it
1680 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001681 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001682 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001683 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001684 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1685 } else if err := a.convo.OverBudget(); err != nil {
1686 // Handle budget issues by appending a message about it
1687 budgetMsg := "We've exceeded our budget. Please ask the user to confirm before continuing by ending the turn."
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001688 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001689 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1690 }
1691
1692 // Combine tool results with user messages
1693 results = append(results, msgs...)
1694
1695 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001696 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001697 resp, err := a.convo.SendMessage(llm.Message{
1698 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001699 Content: results,
1700 })
1701 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001702 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001703 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1704 return true, nil // Return true to continue the conversation, but with no response
1705 }
1706
Sean McCullough96b60dd2025-04-30 09:49:10 -07001707 // Transition back to processing LLM response
1708 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1709
Sean McCullough885a16a2025-04-30 02:49:25 +00001710 if cancelled {
1711 return false, nil
1712 }
1713
1714 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001715}
1716
1717func (a *Agent) overBudget(ctx context.Context) error {
1718 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001719 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001720 m := budgetMessage(err)
1721 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001722 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001723 a.convo.ResetBudget(a.originalBudget)
1724 return err
1725 }
1726 return nil
1727}
1728
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001729func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001730 // Collect all text content
1731 var allText strings.Builder
1732 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001733 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001734 if allText.Len() > 0 {
1735 allText.WriteString("\n\n")
1736 }
1737 allText.WriteString(content.Text)
1738 }
1739 }
1740 return allText.String()
1741}
1742
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001743func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001744 a.mu.Lock()
1745 defer a.mu.Unlock()
1746 return a.convo.CumulativeUsage()
1747}
1748
Earl Lee2e463fb2025-04-17 11:22:22 -07001749// Diff returns a unified diff of changes made since the agent was instantiated.
1750func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001751 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001752 return "", fmt.Errorf("no initial commit reference available")
1753 }
1754
1755 // Find the repository root
1756 ctx := context.Background()
1757
1758 // If a specific commit hash is provided, show just that commit's changes
1759 if commit != nil && *commit != "" {
1760 // Validate that the commit looks like a valid git SHA
1761 if !isValidGitSHA(*commit) {
1762 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1763 }
1764
1765 // Get the diff for just this commit
1766 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1767 cmd.Dir = a.repoRoot
1768 output, err := cmd.CombinedOutput()
1769 if err != nil {
1770 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1771 }
1772 return string(output), nil
1773 }
1774
1775 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001776 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001777 cmd.Dir = a.repoRoot
1778 output, err := cmd.CombinedOutput()
1779 if err != nil {
1780 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1781 }
1782
1783 return string(output), nil
1784}
1785
Philip Zeyliger49edc922025-05-14 09:45:45 -07001786// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1787// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1788func (a *Agent) SketchGitBaseRef() string {
1789 if a.IsInContainer() {
1790 return "sketch-base"
1791 } else {
1792 return "sketch-base-" + a.SessionID()
1793 }
1794}
1795
1796// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1797func (a *Agent) SketchGitBase() string {
1798 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1799 cmd.Dir = a.repoRoot
1800 output, err := cmd.CombinedOutput()
1801 if err != nil {
1802 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1803 return "HEAD"
1804 }
1805 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001806}
1807
Pokey Rule7a113622025-05-12 10:58:45 +01001808// removeGitHooks removes the Git hooks directory from the repository
1809func removeGitHooks(_ context.Context, repoPath string) error {
1810 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1811
1812 // Check if hooks directory exists
1813 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1814 // Directory doesn't exist, nothing to do
1815 return nil
1816 }
1817
1818 // Remove the hooks directory
1819 err := os.RemoveAll(hooksDir)
1820 if err != nil {
1821 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1822 }
1823
1824 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001825 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001826 if err != nil {
1827 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1828 }
1829
1830 return nil
1831}
1832
Philip Zeyligerf2872992025-05-22 10:35:28 -07001833func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001834 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001835 for _, msg := range msgs {
1836 a.pushToOutbox(ctx, msg)
1837 }
1838 return commits, error
1839}
1840
Earl Lee2e463fb2025-04-17 11:22:22 -07001841// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001842// under docker, new HEADs are pushed to a branch according to the slug.
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001843func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001844 ags.mu.Lock()
1845 defer ags.mu.Unlock()
1846
1847 msgs := []AgentMessage{}
1848 if repoRoot == "" {
1849 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001850 }
1851
Philip Zeyligerf2872992025-05-22 10:35:28 -07001852 head, err := resolveRef(ctx, repoRoot, "HEAD")
Earl Lee2e463fb2025-04-17 11:22:22 -07001853 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001854 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001855 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001856 if head == ags.lastHEAD {
1857 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001858 }
1859 defer func() {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001860 ags.lastHEAD = head
Earl Lee2e463fb2025-04-17 11:22:22 -07001861 }()
1862
1863 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1864 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1865 // to the last 100 commits.
1866 var commits []*GitCommit
1867
1868 // Get commits since the initial commit
1869 // Format: <hash>\0<subject>\0<body>\0
1870 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1871 // Limit to 100 commits to avoid overwhelming the user
Philip Zeyligerf2872992025-05-22 10:35:28 -07001872 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, head)
1873 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001874 output, err := cmd.Output()
1875 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001876 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001877 }
1878
1879 // Parse git log output and filter out already seen commits
1880 parsedCommits := parseGitLog(string(output))
1881
1882 var headCommit *GitCommit
1883
1884 // Filter out commits we've already seen
1885 for _, commit := range parsedCommits {
1886 if commit.Hash == head {
1887 headCommit = &commit
1888 }
1889
1890 // Skip if we've seen this commit before. If our head has changed, always include that.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001891 if ags.seenCommits[commit.Hash] && commit.Hash != head {
Earl Lee2e463fb2025-04-17 11:22:22 -07001892 continue
1893 }
1894
1895 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001896 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001897
1898 // Add to our list of new commits
1899 commits = append(commits, &commit)
1900 }
1901
Philip Zeyligerf2872992025-05-22 10:35:28 -07001902 if ags.gitRemoteAddr != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001903 if headCommit == nil {
1904 // I think this can only happen if we have a bug or if there's a race.
1905 headCommit = &GitCommit{}
1906 headCommit.Hash = head
1907 headCommit.Subject = "unknown"
1908 commits = append(commits, headCommit)
1909 }
1910
Earl Lee2e463fb2025-04-17 11:22:22 -07001911 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1912 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1913 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001914
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001915 // Try up to 10 times with incrementing retry numbers if the branch is checked out on the remote
Philip Zeyliger113e2052025-05-09 21:59:40 +00001916 var out []byte
1917 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001918 originalRetryNumber := ags.retryNumber
1919 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001920 for retries := range 10 {
1921 if retries > 0 {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001922 ags.IncrementRetryNumber()
Philip Zeyliger113e2052025-05-09 21:59:40 +00001923 }
1924
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001925 branch := ags.branchNameLocked(branchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001926 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1927 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001928 out, err = cmd.CombinedOutput()
1929
1930 if err == nil {
1931 // Success! Break out of the retry loop
1932 break
1933 }
1934
1935 // Check if this is the "refusing to update checked out branch" error
1936 if !strings.Contains(string(out), "refusing to update checked out branch") {
1937 // This is a different error, so don't retry
1938 break
1939 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00001940 }
1941
1942 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001943 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001944 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001945 finalBranch := ags.branchNameLocked(branchPrefix)
1946 headCommit.PushedBranch = finalBranch
1947 if ags.retryNumber != originalRetryNumber {
1948 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001949 msgs = append(msgs, AgentMessage{
1950 Type: AutoMessageType,
1951 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001952 Content: fmt.Sprintf("Branch renamed from %s to %s because the original branch is currently checked out on the remote.", originalBranchName, finalBranch),
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001953 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00001954 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001955 }
1956 }
1957
1958 // If we found new commits, create a message
1959 if len(commits) > 0 {
1960 msg := AgentMessage{
1961 Type: CommitMessageType,
1962 Timestamp: time.Now(),
1963 Commits: commits,
1964 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001965 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001966 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001967 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001968}
1969
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001970func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001971 return strings.Map(func(r rune) rune {
1972 // lowercase
1973 if r >= 'A' && r <= 'Z' {
1974 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001975 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001976 // replace spaces with dashes
1977 if r == ' ' {
1978 return '-'
1979 }
1980 // allow alphanumerics and dashes
1981 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1982 return r
1983 }
1984 return -1
1985 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001986}
1987
1988// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1989// and returns an array of GitCommit structs.
1990func parseGitLog(output string) []GitCommit {
1991 var commits []GitCommit
1992
1993 // No output means no commits
1994 if len(output) == 0 {
1995 return commits
1996 }
1997
1998 // Split by NULL byte
1999 parts := strings.Split(output, "\x00")
2000
2001 // Process in triplets (hash, subject, body)
2002 for i := 0; i < len(parts); i++ {
2003 // Skip empty parts
2004 if parts[i] == "" {
2005 continue
2006 }
2007
2008 // This should be a hash
2009 hash := strings.TrimSpace(parts[i])
2010
2011 // Make sure we have at least a subject part available
2012 if i+1 >= len(parts) {
2013 break // No more parts available
2014 }
2015
2016 // Get the subject
2017 subject := strings.TrimSpace(parts[i+1])
2018
2019 // Get the body if available
2020 body := ""
2021 if i+2 < len(parts) {
2022 body = strings.TrimSpace(parts[i+2])
2023 }
2024
2025 // Skip to the next triplet
2026 i += 2
2027
2028 commits = append(commits, GitCommit{
2029 Hash: hash,
2030 Subject: subject,
2031 Body: body,
2032 })
2033 }
2034
2035 return commits
2036}
2037
2038func repoRoot(ctx context.Context, dir string) (string, error) {
2039 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2040 stderr := new(strings.Builder)
2041 cmd.Stderr = stderr
2042 cmd.Dir = dir
2043 out, err := cmd.Output()
2044 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002045 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002046 }
2047 return strings.TrimSpace(string(out)), nil
2048}
2049
2050func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2051 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2052 stderr := new(strings.Builder)
2053 cmd.Stderr = stderr
2054 cmd.Dir = dir
2055 out, err := cmd.Output()
2056 if err != nil {
2057 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2058 }
2059 // TODO: validate that out is valid hex
2060 return strings.TrimSpace(string(out)), nil
2061}
2062
2063// isValidGitSHA validates if a string looks like a valid git SHA hash.
2064// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2065func isValidGitSHA(sha string) bool {
2066 // Git SHA must be a hexadecimal string with at least 4 characters
2067 if len(sha) < 4 || len(sha) > 40 {
2068 return false
2069 }
2070
2071 // Check if the string only contains hexadecimal characters
2072 for _, char := range sha {
2073 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2074 return false
2075 }
2076 }
2077
2078 return true
2079}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002080
2081// getGitOrigin returns the URL of the git remote 'origin' if it exists
2082func getGitOrigin(ctx context.Context, dir string) string {
2083 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2084 cmd.Dir = dir
2085 stderr := new(strings.Builder)
2086 cmd.Stderr = stderr
2087 out, err := cmd.Output()
2088 if err != nil {
2089 return ""
2090 }
2091 return strings.TrimSpace(string(out))
2092}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002093
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002094// systemPromptData contains the data used to render the system prompt template
2095type systemPromptData struct {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002096 ClientGOOS string
2097 ClientGOARCH string
2098 WorkingDir string
2099 RepoRoot string
2100 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002101 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002102}
2103
2104// renderSystemPrompt renders the system prompt template.
2105func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002106 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002107 ClientGOOS: a.config.ClientGOOS,
2108 ClientGOARCH: a.config.ClientGOARCH,
2109 WorkingDir: a.workingDir,
2110 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002111 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002112 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002113 }
2114
2115 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2116 if err != nil {
2117 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2118 }
2119 buf := new(strings.Builder)
2120 err = tmpl.Execute(buf, data)
2121 if err != nil {
2122 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2123 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002124 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002125 return buf.String()
2126}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002127
2128// StateTransitionIterator provides an iterator over state transitions.
2129type StateTransitionIterator interface {
2130 // Next blocks until a new state transition is available or context is done.
2131 // Returns nil if the context is cancelled.
2132 Next() *StateTransition
2133 // Close removes the listener and cleans up resources.
2134 Close()
2135}
2136
2137// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2138type StateTransitionIteratorImpl struct {
2139 agent *Agent
2140 ctx context.Context
2141 ch chan StateTransition
2142 unsubscribe func()
2143}
2144
2145// Next blocks until a new state transition is available or the context is cancelled.
2146func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2147 select {
2148 case <-s.ctx.Done():
2149 return nil
2150 case transition, ok := <-s.ch:
2151 if !ok {
2152 return nil
2153 }
2154 transitionCopy := transition
2155 return &transitionCopy
2156 }
2157}
2158
2159// Close removes the listener and cleans up resources.
2160func (s *StateTransitionIteratorImpl) Close() {
2161 if s.unsubscribe != nil {
2162 s.unsubscribe()
2163 s.unsubscribe = nil
2164 }
2165}
2166
2167// NewStateTransitionIterator returns an iterator that receives state transitions.
2168func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2169 a.mu.Lock()
2170 defer a.mu.Unlock()
2171
2172 // Create channel to receive state transitions
2173 ch := make(chan StateTransition, 10)
2174
2175 // Add a listener to the state machine
2176 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2177
2178 return &StateTransitionIteratorImpl{
2179 agent: a,
2180 ctx: ctx,
2181 ch: ch,
2182 unsubscribe: unsubscribe,
2183 }
2184}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002185
2186// setupGitHooks creates or updates git hooks in the specified working directory.
2187func setupGitHooks(workingDir string) error {
2188 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2189
2190 _, err := os.Stat(hooksDir)
2191 if os.IsNotExist(err) {
2192 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2193 }
2194 if err != nil {
2195 return fmt.Errorf("error checking git hooks directory: %w", err)
2196 }
2197
2198 // Define the post-commit hook content
2199 postCommitHook := `#!/bin/bash
2200echo "<post_commit_hook>"
2201echo "Please review this commit message and fix it if it is incorrect."
2202echo "This hook only echos the commit message; it does not modify it."
2203echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2204echo "<last_commit_message>"
2205git log -1 --pretty=%B
2206echo "</last_commit_message>"
2207echo "</post_commit_hook>"
2208`
2209
2210 // Define the prepare-commit-msg hook content
2211 prepareCommitMsgHook := `#!/bin/bash
2212# Add Co-Authored-By and Change-ID trailers to commit messages
2213# Check if these trailers already exist before adding them
2214
2215commit_file="$1"
2216COMMIT_SOURCE="$2"
2217
2218# Skip for merges, squashes, or when using a commit template
2219if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2220 [ "$COMMIT_SOURCE" = "squash" ]; then
2221 exit 0
2222fi
2223
2224commit_msg=$(cat "$commit_file")
2225
2226needs_co_author=true
2227needs_change_id=true
2228
2229# Check if commit message already has Co-Authored-By trailer
2230if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2231 needs_co_author=false
2232fi
2233
2234# Check if commit message already has Change-ID trailer
2235if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2236 needs_change_id=false
2237fi
2238
2239# Only modify if at least one trailer needs to be added
2240if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002241 # Ensure there's a proper blank line before trailers
2242 if [ -s "$commit_file" ]; then
2243 # Check if file ends with newline by reading last character
2244 last_char=$(tail -c 1 "$commit_file")
2245
2246 if [ "$last_char" != "" ]; then
2247 # File doesn't end with newline - add two newlines (complete line + blank line)
2248 echo "" >> "$commit_file"
2249 echo "" >> "$commit_file"
2250 else
2251 # File ends with newline - check if we already have a blank line
2252 last_line=$(tail -1 "$commit_file")
2253 if [ -n "$last_line" ]; then
2254 # Last line has content - add one newline for blank line
2255 echo "" >> "$commit_file"
2256 fi
2257 # If last line is empty, we already have a blank line - don't add anything
2258 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002259 fi
2260
2261 # Add trailers if needed
2262 if [ "$needs_co_author" = true ]; then
2263 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2264 fi
2265
2266 if [ "$needs_change_id" = true ]; then
2267 change_id=$(openssl rand -hex 8)
2268 echo "Change-ID: s${change_id}k" >> "$commit_file"
2269 fi
2270fi
2271`
2272
2273 // Update or create the post-commit hook
2274 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2275 if err != nil {
2276 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2277 }
2278
2279 // Update or create the prepare-commit-msg hook
2280 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2281 if err != nil {
2282 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2283 }
2284
2285 return nil
2286}
2287
2288// updateOrCreateHook creates a new hook file or updates an existing one
2289// by appending the new content if it doesn't already contain it.
2290func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2291 // Check if the hook already exists
2292 buf, err := os.ReadFile(hookPath)
2293 if os.IsNotExist(err) {
2294 // Hook doesn't exist, create it
2295 err = os.WriteFile(hookPath, []byte(content), 0o755)
2296 if err != nil {
2297 return fmt.Errorf("failed to create hook: %w", err)
2298 }
2299 return nil
2300 }
2301 if err != nil {
2302 return fmt.Errorf("error reading existing hook: %w", err)
2303 }
2304
2305 // Hook exists, check if our content is already in it by looking for a distinctive line
2306 code := string(buf)
2307 if strings.Contains(code, distinctiveLine) {
2308 // Already contains our content, nothing to do
2309 return nil
2310 }
2311
2312 // Append our content to the existing hook
2313 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2314 if err != nil {
2315 return fmt.Errorf("failed to open hook for appending: %w", err)
2316 }
2317 defer f.Close()
2318
2319 // Ensure there's a newline at the end of the existing content if needed
2320 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2321 _, err = f.WriteString("\n")
2322 if err != nil {
2323 return fmt.Errorf("failed to add newline to hook: %w", err)
2324 }
2325 }
2326
2327 // Add a separator before our content
2328 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2329 if err != nil {
2330 return fmt.Errorf("failed to append to hook: %w", err)
2331 }
2332
2333 return nil
2334}
Sean McCullough138ec242025-06-02 22:42:06 +00002335
2336// GetPortMonitor returns the port monitor instance for accessing port events
2337func (a *Agent) GetPortMonitor() *PortMonitor {
2338 return a.portMonitor
2339}