blob: 8f9692401382bd3cb8dd40930ccb81b1e5f52aec [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"
Philip Zeyliger194bfa82025-06-24 06:03:06 -070031 "sketch.dev/mcp"
Philip Zeyligerc17ffe32025-06-05 19:49:13 -070032 "sketch.dev/skabandclient"
Earl Lee2e463fb2025-04-17 11:22:22 -070033)
34
35const (
36 userCancelMessage = "user requested agent to stop handling responses"
37)
38
Philip Zeyligerb7c58752025-05-01 10:10:17 -070039type MessageIterator interface {
40 // Next blocks until the next message is available. It may
41 // return nil if the underlying iterator context is done.
42 Next() *AgentMessage
43 Close()
44}
45
Earl Lee2e463fb2025-04-17 11:22:22 -070046type CodingAgent interface {
47 // Init initializes an agent inside a docker container.
48 Init(AgentInit) error
49
50 // Ready returns a channel closed after Init successfully called.
51 Ready() <-chan struct{}
52
53 // URL reports the HTTP URL of this agent.
54 URL() string
55
56 // UserMessage enqueues a message to the agent and returns immediately.
57 UserMessage(ctx context.Context, msg string)
58
Philip Zeyligerb7c58752025-05-01 10:10:17 -070059 // Returns an iterator that finishes when the context is done and
60 // starts with the given message index.
61 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070062
Philip Zeyligereab12de2025-05-14 02:35:53 +000063 // Returns an iterator that notifies of state transitions until the context is done.
64 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
65
Earl Lee2e463fb2025-04-17 11:22:22 -070066 // Loop begins the agent loop returns only when ctx is cancelled.
67 Loop(ctx context.Context)
68
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000069 // BranchPrefix returns the configured branch prefix
70 BranchPrefix() string
71
philip.zeyliger6d3de482025-06-10 19:38:14 -070072 // LinkToGitHub returns whether GitHub branch linking is enabled
73 LinkToGitHub() bool
74
Sean McCulloughedc88dc2025-04-30 02:55:01 +000075 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070076
77 CancelToolUse(toolUseID string, cause error) error
78
79 // Returns a subset of the agent's message history.
80 Messages(start int, end int) []AgentMessage
81
82 // Returns the current number of messages in the history
83 MessageCount() int
84
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070085 TotalUsage() conversation.CumulativeUsage
86 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070087
Earl Lee2e463fb2025-04-17 11:22:22 -070088 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000089 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070090
91 // Diff returns a unified diff of changes made since the agent was instantiated.
92 // If commit is non-nil, it shows the diff for just that specific commit.
93 Diff(commit *string) (string, error)
94
Philip Zeyliger49edc922025-05-14 09:45:45 -070095 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
96 // starts out as the commit where sketch started, but a user can move it if need
97 // be, for example in the case of a rebase. It is stored as a git tag.
98 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070099
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000100 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
101 // (Typically, this is "sketch-base")
102 SketchGitBaseRef() string
103
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700104 // Slug returns the slug identifier for this session.
105 Slug() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700106
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000107 // BranchName returns the git branch name for the conversation.
108 BranchName() string
109
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700110 // IncrementRetryNumber increments the retry number for branch naming conflicts.
111 IncrementRetryNumber()
112
Earl Lee2e463fb2025-04-17 11:22:22 -0700113 // OS returns the operating system of the client.
114 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000115
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000116 // SessionID returns the unique session identifier.
117 SessionID() string
118
philip.zeyliger8773e682025-06-11 21:36:21 -0700119 // SSHConnectionString returns the SSH connection string for the container.
120 SSHConnectionString() string
121
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000122 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700123 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000124
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000125 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
126 OutstandingLLMCallCount() int
127
128 // OutstandingToolCalls returns the names of outstanding tool calls.
129 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000130 OutsideOS() string
131 OutsideHostname() string
132 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000133 GitOrigin() string
Philip Zeyliger64f60462025-06-16 13:57:10 -0700134
bankseancad67b02025-06-27 21:57:05 +0000135 // GitUsername returns the git user name from the agent config.
136 GitUsername() string
137
Philip Zeyliger64f60462025-06-16 13:57:10 -0700138 // DiffStats returns the number of lines added and removed from sketch-base to HEAD
139 DiffStats() (int, int)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000140 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
141 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700142
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700143 // IsInContainer returns true if the agent is running in a container
144 IsInContainer() bool
145 // FirstMessageIndex returns the index of the first message in the current conversation
146 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700147
148 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700149 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
150 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700151
152 // CompactConversation compacts the current conversation by generating a summary
153 // and restarting the conversation with that summary as the initial context
154 CompactConversation(ctx context.Context) error
Philip Zeyligerda623b52025-07-04 01:12:38 +0000155
Philip Zeyliger0113be52025-06-07 23:53:41 +0000156 // SkabandAddr returns the skaband address if configured
157 SkabandAddr() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700158}
159
160type CodingAgentMessageType string
161
162const (
163 UserMessageType CodingAgentMessageType = "user"
164 AgentMessageType CodingAgentMessageType = "agent"
165 ErrorMessageType CodingAgentMessageType = "error"
166 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
167 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700168 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
169 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
170 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700171
172 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
173)
174
175type AgentMessage struct {
176 Type CodingAgentMessageType `json:"type"`
177 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
178 EndOfTurn bool `json:"end_of_turn"`
179
180 Content string `json:"content"`
181 ToolName string `json:"tool_name,omitempty"`
182 ToolInput string `json:"input,omitempty"`
183 ToolResult string `json:"tool_result,omitempty"`
184 ToolError bool `json:"tool_error,omitempty"`
185 ToolCallId string `json:"tool_call_id,omitempty"`
186
187 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
188 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
189
Sean McCulloughd9f13372025-04-21 15:08:49 -0700190 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
191 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
192
Earl Lee2e463fb2025-04-17 11:22:22 -0700193 // Commits is a list of git commits for a commit message
194 Commits []*GitCommit `json:"commits,omitempty"`
195
196 Timestamp time.Time `json:"timestamp"`
197 ConversationID string `json:"conversation_id"`
198 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700199 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700200
201 // Message timing information
202 StartTime *time.Time `json:"start_time,omitempty"`
203 EndTime *time.Time `json:"end_time,omitempty"`
204 Elapsed *time.Duration `json:"elapsed,omitempty"`
205
206 // Turn duration - the time taken for a complete agent turn
207 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
208
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000209 // HideOutput indicates that this message should not be rendered in the UI.
210 // This is useful for subconversations that generate output that shouldn't be shown to the user.
211 HideOutput bool `json:"hide_output,omitempty"`
212
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700213 // TodoContent contains the agent's todo file content when it has changed
214 TodoContent *string `json:"todo_content,omitempty"`
215
Earl Lee2e463fb2025-04-17 11:22:22 -0700216 Idx int `json:"idx"`
217}
218
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000219// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700220func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700221 if convo == nil {
222 m.ConversationID = ""
223 m.ParentConversationID = nil
224 return
225 }
226 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000227 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700228 if convo.Parent != nil {
229 m.ParentConversationID = &convo.Parent.ID
230 }
231}
232
Earl Lee2e463fb2025-04-17 11:22:22 -0700233// GitCommit represents a single git commit for a commit message
234type GitCommit struct {
235 Hash string `json:"hash"` // Full commit hash
236 Subject string `json:"subject"` // Commit subject line
237 Body string `json:"body"` // Full commit message body
238 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
239}
240
241// ToolCall represents a single tool call within an agent message
242type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700243 Name string `json:"name"`
244 Input string `json:"input"`
245 ToolCallId string `json:"tool_call_id"`
246 ResultMessage *AgentMessage `json:"result_message,omitempty"`
247 Args string `json:"args,omitempty"`
248 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700249}
250
251func (a *AgentMessage) Attr() slog.Attr {
252 var attrs []any = []any{
253 slog.String("type", string(a.Type)),
254 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700255 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700256 if a.EndOfTurn {
257 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
258 }
259 if a.Content != "" {
260 attrs = append(attrs, slog.String("content", a.Content))
261 }
262 if a.ToolName != "" {
263 attrs = append(attrs, slog.String("tool_name", a.ToolName))
264 }
265 if a.ToolInput != "" {
266 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
267 }
268 if a.Elapsed != nil {
269 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
270 }
271 if a.TurnDuration != nil {
272 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
273 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700274 if len(a.ToolResult) > 0 {
275 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700276 }
277 if a.ToolError {
278 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
279 }
280 if len(a.ToolCalls) > 0 {
281 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
282 for i, tc := range a.ToolCalls {
283 toolCallAttrs = append(toolCallAttrs, slog.Group(
284 fmt.Sprintf("tool_call_%d", i),
285 slog.String("name", tc.Name),
286 slog.String("input", tc.Input),
287 ))
288 }
289 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
290 }
291 if a.ConversationID != "" {
292 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
293 }
294 if a.ParentConversationID != nil {
295 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
296 }
297 if a.Usage != nil && !a.Usage.IsZero() {
298 attrs = append(attrs, a.Usage.Attr())
299 }
300 // TODO: timestamp, convo ids, idx?
301 return slog.Group("agent_message", attrs...)
302}
303
304func errorMessage(err error) AgentMessage {
305 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
306 if os.Getenv(("DEBUG")) == "1" {
307 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
308 }
309
310 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
311}
312
313func budgetMessage(err error) AgentMessage {
314 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
315}
316
317// ConvoInterface defines the interface for conversation interactions
318type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700319 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700320 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700321 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700322 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700323 SendMessage(message llm.Message) (*llm.Response, error)
324 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700325 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000326 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700327 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700328 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700329 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700330}
331
Philip Zeyligerf2872992025-05-22 10:35:28 -0700332// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700333// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700334// any time we notice we need to.
335type AgentGitState struct {
336 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700337 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700338 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000339 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700340 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700341 slug string // Human-readable session identifier
342 retryNumber int // Number to append when branch conflicts occur
Philip Zeyliger64f60462025-06-16 13:57:10 -0700343 linesAdded int // Lines added from sketch-base to HEAD
344 linesRemoved int // Lines removed from sketch-base to HEAD
Philip Zeyligerf2872992025-05-22 10:35:28 -0700345}
346
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700347func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700348 ags.mu.Lock()
349 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700350 if ags.slug != slug {
351 ags.retryNumber = 0
352 }
353 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700354}
355
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700356func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700357 ags.mu.Lock()
358 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700359 return ags.slug
360}
361
362func (ags *AgentGitState) IncrementRetryNumber() {
363 ags.mu.Lock()
364 defer ags.mu.Unlock()
365 ags.retryNumber++
366}
367
Philip Zeyliger64f60462025-06-16 13:57:10 -0700368func (ags *AgentGitState) DiffStats() (int, int) {
369 ags.mu.Lock()
370 defer ags.mu.Unlock()
371 return ags.linesAdded, ags.linesRemoved
372}
373
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700374// HasSeenCommits returns true if any commits have been processed
375func (ags *AgentGitState) HasSeenCommits() bool {
376 ags.mu.Lock()
377 defer ags.mu.Unlock()
378 return len(ags.seenCommits) > 0
379}
380
381func (ags *AgentGitState) RetryNumber() int {
382 ags.mu.Lock()
383 defer ags.mu.Unlock()
384 return ags.retryNumber
385}
386
387func (ags *AgentGitState) BranchName(prefix string) string {
388 ags.mu.Lock()
389 defer ags.mu.Unlock()
390 return ags.branchNameLocked(prefix)
391}
392
393func (ags *AgentGitState) branchNameLocked(prefix string) string {
394 if ags.slug == "" {
395 return ""
396 }
397 if ags.retryNumber == 0 {
398 return prefix + ags.slug
399 }
400 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700401}
402
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000403func (ags *AgentGitState) Upstream() string {
404 ags.mu.Lock()
405 defer ags.mu.Unlock()
406 return ags.upstream
407}
408
Earl Lee2e463fb2025-04-17 11:22:22 -0700409type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700410 convo ConvoInterface
411 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700412 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700413 workingDir string
414 repoRoot string // workingDir may be a subdir of repoRoot
415 url string
416 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000417 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700418 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000419 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700420 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700421 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000422 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700423 // State machine to track agent state
424 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000425 // Outside information
426 outsideHostname string
427 outsideOS string
428 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000429 // URL of the git remote 'origin' if it exists
430 gitOrigin string
Philip Zeyliger194bfa82025-06-24 06:03:06 -0700431 // MCP manager for handling MCP server connections
432 mcpManager *mcp.MCPManager
Earl Lee2e463fb2025-04-17 11:22:22 -0700433
434 // Time when the current turn started (reset at the beginning of InnerLoop)
435 startOfTurn time.Time
436
437 // Inbox - for messages from the user to the agent.
438 // sent on by UserMessage
439 // . e.g. when user types into the chat textarea
440 // read from by GatherMessages
441 inbox chan string
442
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000443 // protects cancelTurn
444 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700445 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000446 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700447
448 // protects following
449 mu sync.Mutex
450
451 // Stores all messages for this agent
452 history []AgentMessage
453
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700454 // Iterators add themselves here when they're ready to be notified of new messages.
455 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700456
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000457 // Track outstanding LLM call IDs
458 outstandingLLMCalls map[string]struct{}
459
460 // Track outstanding tool calls by ID with their names
461 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700462}
463
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700464// NewIterator implements CodingAgent.
465func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
466 a.mu.Lock()
467 defer a.mu.Unlock()
468
469 return &MessageIteratorImpl{
470 agent: a,
471 ctx: ctx,
472 nextMessageIdx: nextMessageIdx,
473 ch: make(chan *AgentMessage, 100),
474 }
475}
476
477type MessageIteratorImpl struct {
478 agent *Agent
479 ctx context.Context
480 nextMessageIdx int
481 ch chan *AgentMessage
482 subscribed bool
483}
484
485func (m *MessageIteratorImpl) Close() {
486 m.agent.mu.Lock()
487 defer m.agent.mu.Unlock()
488 // Delete ourselves from the subscribers list
489 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
490 return x == m.ch
491 })
492 close(m.ch)
493}
494
495func (m *MessageIteratorImpl) Next() *AgentMessage {
496 // We avoid subscription at creation to let ourselves catch up to "current state"
497 // before subscribing.
498 if !m.subscribed {
499 m.agent.mu.Lock()
500 if m.nextMessageIdx < len(m.agent.history) {
501 msg := &m.agent.history[m.nextMessageIdx]
502 m.nextMessageIdx++
503 m.agent.mu.Unlock()
504 return msg
505 }
506 // The next message doesn't exist yet, so let's subscribe
507 m.agent.subscribers = append(m.agent.subscribers, m.ch)
508 m.subscribed = true
509 m.agent.mu.Unlock()
510 }
511
512 for {
513 select {
514 case <-m.ctx.Done():
515 m.agent.mu.Lock()
516 // Delete ourselves from the subscribers list
517 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
518 return x == m.ch
519 })
520 m.subscribed = false
521 m.agent.mu.Unlock()
522 return nil
523 case msg, ok := <-m.ch:
524 if !ok {
525 // Close may have been called
526 return nil
527 }
528 if msg.Idx == m.nextMessageIdx {
529 m.nextMessageIdx++
530 return msg
531 }
532 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
533 panic("out of order message")
534 }
535 }
536}
537
Sean McCulloughd9d45812025-04-30 16:53:41 -0700538// Assert that Agent satisfies the CodingAgent interface.
539var _ CodingAgent = &Agent{}
540
541// StateName implements CodingAgent.
542func (a *Agent) CurrentStateName() string {
543 if a.stateMachine == nil {
544 return ""
545 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000546 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700547}
548
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700549// CurrentTodoContent returns the current todo list data as JSON.
550// It returns an empty string if no todos exist.
551func (a *Agent) CurrentTodoContent() string {
552 todoPath := claudetool.TodoFilePath(a.config.SessionID)
553 content, err := os.ReadFile(todoPath)
554 if err != nil {
555 return ""
556 }
557 return string(content)
558}
559
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700560// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
561func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
562 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.
563
564IMPORTANT: 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.
565
566Please create a detailed summary that includes:
567
5681. **User's Request**: What did the user originally ask me to do? What was their goal?
569
5702. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
571
5723. **Key Technical Decisions**: What important technical choices were made during our work and why?
573
5744. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
575
5765. **Next Steps**: What still needs to be done to complete the user's request?
577
5786. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
579
580Focus 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.
581
582Reply with ONLY the summary content - no meta-commentary about creating the summary.`
583
584 userMessage := llm.UserStringMessage(msg)
585 // Use a subconversation with history to get the summary
586 // TODO: We don't have any tools here, so we should have enough tokens
587 // to capture a summary, but we may need to modify the history (e.g., remove
588 // TODO data) to save on some tokens.
589 convo := a.convo.SubConvoWithHistory()
590
591 // Modify the system prompt to provide context about the original task
592 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000593 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 -0700594
595Your 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.
596
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000597Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700598
599 resp, err := convo.SendMessage(userMessage)
600 if err != nil {
601 a.pushToOutbox(ctx, errorMessage(err))
602 return "", err
603 }
604 textContent := collectTextContent(resp)
605
606 // Restore original system prompt (though this subconvo will be discarded)
607 convo.SystemPrompt = originalSystemPrompt
608
609 return textContent, nil
610}
611
612// CompactConversation compacts the current conversation by generating a summary
613// and restarting the conversation with that summary as the initial context
614func (a *Agent) CompactConversation(ctx context.Context) error {
615 summary, err := a.generateConversationSummary(ctx)
616 if err != nil {
617 return fmt.Errorf("failed to generate conversation summary: %w", err)
618 }
619
620 a.mu.Lock()
621
622 // Get usage information before resetting conversation
623 lastUsage := a.convo.LastUsage()
624 contextWindow := a.config.Service.TokenContextWindow()
625 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
626
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000627 // Preserve cumulative usage across compaction
628 cumulativeUsage := a.convo.CumulativeUsage()
629
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700630 // Reset conversation state but keep all other state (git, working dir, etc.)
631 a.firstMessageIndex = len(a.history)
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000632 a.convo = a.initConvoWithUsage(&cumulativeUsage)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700633
634 a.mu.Unlock()
635
636 // Create informative compaction message with token details
637 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
638 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
639 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
640
641 a.pushToOutbox(ctx, AgentMessage{
642 Type: CompactMessageType,
643 Content: compactionMsg,
644 })
645
646 a.pushToOutbox(ctx, AgentMessage{
647 Type: UserMessageType,
648 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),
649 })
650 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)
651
652 return nil
653}
654
Earl Lee2e463fb2025-04-17 11:22:22 -0700655func (a *Agent) URL() string { return a.url }
656
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000657// BranchName returns the git branch name for the conversation.
658func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700659 return a.gitState.BranchName(a.config.BranchPrefix)
660}
661
662// Slug returns the slug identifier for this conversation.
663func (a *Agent) Slug() string {
664 return a.gitState.Slug()
665}
666
667// IncrementRetryNumber increments the retry number for branch naming conflicts
668func (a *Agent) IncrementRetryNumber() {
669 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000670}
671
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000672// OutstandingLLMCallCount returns the number of outstanding LLM calls.
673func (a *Agent) OutstandingLLMCallCount() int {
674 a.mu.Lock()
675 defer a.mu.Unlock()
676 return len(a.outstandingLLMCalls)
677}
678
679// OutstandingToolCalls returns the names of outstanding tool calls.
680func (a *Agent) OutstandingToolCalls() []string {
681 a.mu.Lock()
682 defer a.mu.Unlock()
683
684 tools := make([]string, 0, len(a.outstandingToolCalls))
685 for _, toolName := range a.outstandingToolCalls {
686 tools = append(tools, toolName)
687 }
688 return tools
689}
690
Earl Lee2e463fb2025-04-17 11:22:22 -0700691// OS returns the operating system of the client.
692func (a *Agent) OS() string {
693 return a.config.ClientGOOS
694}
695
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000696func (a *Agent) SessionID() string {
697 return a.config.SessionID
698}
699
philip.zeyliger8773e682025-06-11 21:36:21 -0700700// SSHConnectionString returns the SSH connection string for the container.
701func (a *Agent) SSHConnectionString() string {
702 return a.config.SSHConnectionString
703}
704
Philip Zeyliger18532b22025-04-23 21:11:46 +0000705// OutsideOS returns the operating system of the outside system.
706func (a *Agent) OutsideOS() string {
707 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000708}
709
Philip Zeyliger18532b22025-04-23 21:11:46 +0000710// OutsideHostname returns the hostname of the outside system.
711func (a *Agent) OutsideHostname() string {
712 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000713}
714
Philip Zeyliger18532b22025-04-23 21:11:46 +0000715// OutsideWorkingDir returns the working directory on the outside system.
716func (a *Agent) OutsideWorkingDir() string {
717 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000718}
719
720// GitOrigin returns the URL of the git remote 'origin' if it exists.
721func (a *Agent) GitOrigin() string {
722 return a.gitOrigin
723}
724
bankseancad67b02025-06-27 21:57:05 +0000725// GitUsername returns the git user name from the agent config.
726func (a *Agent) GitUsername() string {
727 return a.config.GitUsername
728}
729
Philip Zeyliger64f60462025-06-16 13:57:10 -0700730// DiffStats returns the number of lines added and removed from sketch-base to HEAD
731func (a *Agent) DiffStats() (int, int) {
732 return a.gitState.DiffStats()
733}
734
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000735func (a *Agent) OpenBrowser(url string) {
736 if !a.IsInContainer() {
737 browser.Open(url)
738 return
739 }
740 // We're in Docker, need to send a request to the Git server
741 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700742 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000743 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700744 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000745 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700746 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000747 return
748 }
749 defer resp.Body.Close()
750 if resp.StatusCode == http.StatusOK {
751 return
752 }
753 body, _ := io.ReadAll(resp.Body)
754 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
755}
756
Sean McCullough96b60dd2025-04-30 09:49:10 -0700757// CurrentState returns the current state of the agent's state machine.
758func (a *Agent) CurrentState() State {
759 return a.stateMachine.CurrentState()
760}
761
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700762func (a *Agent) IsInContainer() bool {
763 return a.config.InDocker
764}
765
766func (a *Agent) FirstMessageIndex() int {
767 a.mu.Lock()
768 defer a.mu.Unlock()
769 return a.firstMessageIndex
770}
771
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700772// SetSlug sets a human-readable identifier for the conversation.
773func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700774 a.mu.Lock()
775 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700776
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700777 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000778 convo, ok := a.convo.(*conversation.Convo)
779 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700780 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000781 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700782}
783
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000784// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700785func (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 +0000786 // Track the tool call
787 a.mu.Lock()
788 a.outstandingToolCalls[id] = toolName
789 a.mu.Unlock()
790}
791
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700792// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
793// If there's only one element in the array and it's a text type, it returns that text directly.
794// It also processes nested ToolResult arrays recursively.
795func contentToString(contents []llm.Content) string {
796 if len(contents) == 0 {
797 return ""
798 }
799
800 // If there's only one element and it's a text type, return it directly
801 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
802 return contents[0].Text
803 }
804
805 // Otherwise, concatenate all text content
806 var result strings.Builder
807 for _, content := range contents {
808 if content.Type == llm.ContentTypeText {
809 result.WriteString(content.Text)
810 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
811 // Recursively process nested tool results
812 result.WriteString(contentToString(content.ToolResult))
813 }
814 }
815
816 return result.String()
817}
818
Earl Lee2e463fb2025-04-17 11:22:22 -0700819// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700820func (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 +0000821 // Remove the tool call from outstanding calls
822 a.mu.Lock()
823 delete(a.outstandingToolCalls, toolID)
824 a.mu.Unlock()
825
Earl Lee2e463fb2025-04-17 11:22:22 -0700826 m := AgentMessage{
827 Type: ToolUseMessageType,
828 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700829 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700830 ToolError: content.ToolError,
831 ToolName: toolName,
832 ToolInput: string(toolInput),
833 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700834 StartTime: content.ToolUseStartTime,
835 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700836 }
837
838 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700839 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
840 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700841 m.Elapsed = &elapsed
842 }
843
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700844 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700845 a.pushToOutbox(ctx, m)
846}
847
848// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700849func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000850 a.mu.Lock()
851 defer a.mu.Unlock()
852 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700853 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
854}
855
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700856// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700857// that need to be displayed (as well as tool calls that we send along when
858// they're done). (It would be reasonable to also mention tool calls when they're
859// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700860func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000861 // Remove the LLM call from outstanding calls
862 a.mu.Lock()
863 delete(a.outstandingLLMCalls, id)
864 a.mu.Unlock()
865
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700866 if resp == nil {
867 // LLM API call failed
868 m := AgentMessage{
869 Type: ErrorMessageType,
870 Content: "API call failed, type 'continue' to try again",
871 }
872 m.SetConvo(convo)
873 a.pushToOutbox(ctx, m)
874 return
875 }
876
Earl Lee2e463fb2025-04-17 11:22:22 -0700877 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700878 if convo.Parent == nil { // subconvos never end the turn
879 switch resp.StopReason {
880 case llm.StopReasonToolUse:
881 // Check whether any of the tool calls are for tools that should end the turn
882 ToolSearch:
883 for _, part := range resp.Content {
884 if part.Type != llm.ContentTypeToolUse {
885 continue
886 }
Sean McCullough021557a2025-05-05 23:20:53 +0000887 // Find the tool by name
888 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700889 if tool.Name == part.ToolName {
890 endOfTurn = tool.EndsTurn
891 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000892 }
893 }
Sean McCullough021557a2025-05-05 23:20:53 +0000894 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700895 default:
896 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000897 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700898 }
899 m := AgentMessage{
900 Type: AgentMessageType,
901 Content: collectTextContent(resp),
902 EndOfTurn: endOfTurn,
903 Usage: &resp.Usage,
904 StartTime: resp.StartTime,
905 EndTime: resp.EndTime,
906 }
907
908 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700909 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700910 var toolCalls []ToolCall
911 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700912 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700913 toolCalls = append(toolCalls, ToolCall{
914 Name: part.ToolName,
915 Input: string(part.ToolInput),
916 ToolCallId: part.ID,
917 })
918 }
919 }
920 m.ToolCalls = toolCalls
921 }
922
923 // Calculate the elapsed time if both start and end times are set
924 if resp.StartTime != nil && resp.EndTime != nil {
925 elapsed := resp.EndTime.Sub(*resp.StartTime)
926 m.Elapsed = &elapsed
927 }
928
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700929 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700930 a.pushToOutbox(ctx, m)
931}
932
933// WorkingDir implements CodingAgent.
934func (a *Agent) WorkingDir() string {
935 return a.workingDir
936}
937
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000938// RepoRoot returns the git repository root directory.
939func (a *Agent) RepoRoot() string {
940 return a.repoRoot
941}
942
Earl Lee2e463fb2025-04-17 11:22:22 -0700943// MessageCount implements CodingAgent.
944func (a *Agent) MessageCount() int {
945 a.mu.Lock()
946 defer a.mu.Unlock()
947 return len(a.history)
948}
949
950// Messages implements CodingAgent.
951func (a *Agent) Messages(start int, end int) []AgentMessage {
952 a.mu.Lock()
953 defer a.mu.Unlock()
954 return slices.Clone(a.history[start:end])
955}
956
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700957// ShouldCompact checks if the conversation should be compacted based on token usage
958func (a *Agent) ShouldCompact() bool {
959 // Get the threshold from environment variable, default to 0.94 (94%)
960 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
961 // and a little bit of buffer.)
962 thresholdRatio := 0.94
963 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
964 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
965 thresholdRatio = parsed
966 }
967 }
968
969 // Get the most recent usage to check current context size
970 lastUsage := a.convo.LastUsage()
971
972 if lastUsage.InputTokens == 0 {
973 // No API calls made yet
974 return false
975 }
976
977 // Calculate the current context size from the last API call
978 // This includes all tokens that were part of the input context:
979 // - Input tokens (user messages, system prompt, conversation history)
980 // - Cache read tokens (cached parts of the context)
981 // - Cache creation tokens (new parts being cached)
982 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
983
984 // Get the service's token context window
985 service := a.config.Service
986 contextWindow := service.TokenContextWindow()
987
988 // Calculate threshold
989 threshold := uint64(float64(contextWindow) * thresholdRatio)
990
991 // Check if we've exceeded the threshold
992 return currentContextSize >= threshold
993}
994
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700995func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700996 return a.originalBudget
997}
998
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000999// Upstream returns the upstream branch for git work
1000func (a *Agent) Upstream() string {
1001 return a.gitState.Upstream()
1002}
1003
Earl Lee2e463fb2025-04-17 11:22:22 -07001004// AgentConfig contains configuration for creating a new Agent.
1005type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001006 Context context.Context
1007 Service llm.Service
1008 Budget conversation.Budget
1009 GitUsername string
1010 GitEmail string
1011 SessionID string
1012 ClientGOOS string
1013 ClientGOARCH string
1014 InDocker bool
1015 OneShot bool
1016 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +00001017 // Outside information
1018 OutsideHostname string
1019 OutsideOS string
1020 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001021
1022 // Outtie's HTTP to, e.g., open a browser
1023 OutsideHTTP string
1024 // Outtie's Git server
1025 GitRemoteAddr string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001026 // Upstream branch for git work
1027 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001028 // Commit to checkout from Outtie
1029 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001030 // Prefix for git branches created by sketch
1031 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001032 // LinkToGitHub enables GitHub branch linking in UI
1033 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001034 // SSH connection string for connecting to the container
1035 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001036 // Skaband client for session history (optional)
1037 SkabandClient *skabandclient.SkabandClient
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001038 // MCP server configurations
1039 MCPServers []string
Earl Lee2e463fb2025-04-17 11:22:22 -07001040}
1041
1042// NewAgent creates a new Agent.
1043// It is not usable until Init() is called.
1044func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001045 // Set default branch prefix if not specified
1046 if config.BranchPrefix == "" {
1047 config.BranchPrefix = "sketch/"
1048 }
1049
Earl Lee2e463fb2025-04-17 11:22:22 -07001050 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001051 config: config,
1052 ready: make(chan struct{}),
1053 inbox: make(chan string, 100),
1054 subscribers: make([]chan *AgentMessage, 0),
1055 startedAt: time.Now(),
1056 originalBudget: config.Budget,
1057 gitState: AgentGitState{
1058 seenCommits: make(map[string]bool),
1059 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001060 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001061 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001062 outsideHostname: config.OutsideHostname,
1063 outsideOS: config.OutsideOS,
1064 outsideWorkingDir: config.OutsideWorkingDir,
1065 outstandingLLMCalls: make(map[string]struct{}),
1066 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001067 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001068 workingDir: config.WorkingDir,
1069 outsideHTTP: config.OutsideHTTP,
Philip Zeyligerda623b52025-07-04 01:12:38 +00001070
1071 mcpManager: mcp.NewMCPManager(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001072 }
1073 return agent
1074}
1075
1076type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001077 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001078
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001079 InDocker bool
1080 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001081}
1082
1083func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001084 if a.convo != nil {
1085 return fmt.Errorf("Agent.Init: already initialized")
1086 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001087 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001088 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001089
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001090 if !ini.NoGit {
1091 // Capture the original origin before we potentially replace it below
1092 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
Philip Zeyligere1c8b7b2025-07-03 14:50:26 -07001093
1094 // Configure git user settings
1095 if a.config.GitEmail != "" {
1096 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.email", a.config.GitEmail)
1097 cmd.Dir = a.workingDir
1098 if out, err := cmd.CombinedOutput(); err != nil {
1099 return fmt.Errorf("git config --global user.email: %s: %v", out, err)
1100 }
1101 }
1102 if a.config.GitUsername != "" {
1103 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.name", a.config.GitUsername)
1104 cmd.Dir = a.workingDir
1105 if out, err := cmd.CombinedOutput(); err != nil {
1106 return fmt.Errorf("git config --global user.name: %s: %v", out, err)
1107 }
1108 }
1109 // Configure git http.postBuffer
1110 cmd := exec.CommandContext(ctx, "git", "config", "--global", "http.postBuffer", "524288000")
1111 cmd.Dir = a.workingDir
1112 if out, err := cmd.CombinedOutput(); err != nil {
1113 return fmt.Errorf("git config --global http.postBuffer: %s: %v", out, err)
1114 }
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001115 }
1116
Philip Zeyliger222bf412025-06-04 16:42:58 +00001117 // If a remote git addr was specified, we configure the origin remote
Philip Zeyligerf2872992025-05-22 10:35:28 -07001118 if a.gitState.gitRemoteAddr != "" {
1119 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
Philip Zeyliger222bf412025-06-04 16:42:58 +00001120
1121 // Remove existing origin remote if it exists
1122 cmd := exec.CommandContext(ctx, "git", "remote", "remove", "origin")
Philip Zeyligerf2872992025-05-22 10:35:28 -07001123 cmd.Dir = a.workingDir
1124 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001125 // Ignore error if origin doesn't exist
1126 slog.DebugContext(ctx, "git remote remove origin (ignoring if not exists)", slog.String("output", string(out)))
Philip Zeyligerf2872992025-05-22 10:35:28 -07001127 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001128
1129 // Add the new remote as origin
1130 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", a.gitState.gitRemoteAddr)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001131 cmd.Dir = a.workingDir
1132 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001133 return fmt.Errorf("git remote add origin: %s: %v", out, err)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001134 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001135
Philip Zeyligerf2872992025-05-22 10:35:28 -07001136 }
1137
1138 // If a commit was specified, we fetch and reset to it.
1139 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001140 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1141
Earl Lee2e463fb2025-04-17 11:22:22 -07001142 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001143 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001144 if out, err := cmd.CombinedOutput(); err != nil {
1145 return fmt.Errorf("git stash: %s: %v", out, err)
1146 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001147 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001148 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001149 if out, err := cmd.CombinedOutput(); err != nil {
1150 return fmt.Errorf("git fetch: %s: %w", out, err)
1151 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001152 // The -B resets the branch if it already exists (or creates it if it doesn't)
1153 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001154 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001155 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1156 // Remove git hooks if they exist and retry
1157 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001158 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001159 if _, statErr := os.Stat(hookPath); statErr == nil {
1160 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1161 slog.String("error", err.Error()),
1162 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001163 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001164 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1165 }
1166
1167 // Retry the checkout operation
Philip Zeyliger1417b692025-06-12 11:07:04 -07001168 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001169 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001170 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001171 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 +01001172 }
1173 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001174 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001175 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001176 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001177 } else if a.IsInContainer() {
1178 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1179 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1180 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1181 cmd.Dir = a.workingDir
1182 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1183 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1184 }
1185 } else {
1186 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001187 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001188
1189 if ini.HostAddr != "" {
1190 a.url = "http://" + ini.HostAddr
1191 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001192
1193 if !ini.NoGit {
1194 repoRoot, err := repoRoot(ctx, a.workingDir)
1195 if err != nil {
1196 return fmt.Errorf("repoRoot: %w", err)
1197 }
1198 a.repoRoot = repoRoot
1199
Earl Lee2e463fb2025-04-17 11:22:22 -07001200 if err != nil {
1201 return fmt.Errorf("resolveRef: %w", err)
1202 }
Philip Zeyliger49edc922025-05-14 09:45:45 -07001203
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001204 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001205 if err := setupGitHooks(a.repoRoot); err != nil {
1206 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1207 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001208 }
1209
Philip Zeyliger49edc922025-05-14 09:45:45 -07001210 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1211 cmd.Dir = repoRoot
1212 if out, err := cmd.CombinedOutput(); err != nil {
1213 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1214 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001215
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001216 slog.Info("running codebase analysis")
1217 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1218 if err != nil {
1219 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001220 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001221 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001222
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001223 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001224 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001225 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001226 }
1227 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001228
Earl Lee2e463fb2025-04-17 11:22:22 -07001229 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001230 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001231 a.convo = a.initConvo()
1232 close(a.ready)
1233 return nil
1234}
1235
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001236//go:embed agent_system_prompt.txt
1237var agentSystemPrompt string
1238
Earl Lee2e463fb2025-04-17 11:22:22 -07001239// initConvo initializes the conversation.
1240// It must not be called until all agent fields are initialized,
1241// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001242func (a *Agent) initConvo() *conversation.Convo {
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001243 return a.initConvoWithUsage(nil)
1244}
1245
1246// initConvoWithUsage initializes the conversation with optional preserved usage.
1247func (a *Agent) initConvoWithUsage(usage *conversation.CumulativeUsage) *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001248 ctx := a.config.Context
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001249 convo := conversation.New(ctx, a.config.Service, usage)
Earl Lee2e463fb2025-04-17 11:22:22 -07001250 convo.PromptCaching = true
1251 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001252 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001253 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001254
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001255 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1256 bashPermissionCheck := func(command string) error {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001257 if a.gitState.Slug() != "" {
1258 return nil // branch is set up
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001259 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001260 willCommit, err := bashkit.WillRunGitCommit(command)
1261 if err != nil {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001262 return nil // fail open
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001263 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001264 if willCommit {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001265 return fmt.Errorf("you must use the set-slug tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001266 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001267 return nil
1268 }
1269
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001270 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001271
Earl Lee2e463fb2025-04-17 11:22:22 -07001272 // Register all tools with the conversation
1273 // When adding, removing, or modifying tools here, double-check that the termui tool display
1274 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001275
1276 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001277 _, supportsScreenshots := a.config.Service.(*ant.Service)
1278 var bTools []*llm.Tool
1279 var browserCleanup func()
1280
1281 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1282 // Add cleanup function to context cancel
1283 go func() {
1284 <-a.config.Context.Done()
1285 browserCleanup()
1286 }()
1287 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001288
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001289 convo.Tools = []*llm.Tool{
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001290 bashTool, claudetool.Keyword, claudetool.Patch(a.patchCallback),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001291 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.setSlugTool(), a.commitMessageStyleTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001292 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001293 }
1294
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001295 // One-shot mode is non-interactive, multiple choice requires human response
1296 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001297 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001298 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001299
1300 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001301
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001302 // Add MCP tools if configured
1303 if len(a.config.MCPServers) > 0 {
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001304
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001305 slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers))
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001306 serverConfigs, parseErrors := mcp.ParseServerConfigs(ctx, a.config.MCPServers)
1307
1308 // Replace any headers with value _sketch_public_key_ and _sketch_session_id_ with those values.
1309 for i := range serverConfigs {
1310 if serverConfigs[i].Headers != nil {
1311 for key, value := range serverConfigs[i].Headers {
Philip Zeyligerf2814ea2025-06-30 10:16:50 -07001312 // Replace env placeholders. E.g., "env:FOO" becomes os.Getenv("FOO")
1313 if strings.HasPrefix(value, "env:") {
1314 serverConfigs[i].Headers[key] = os.Getenv(value[4:])
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001315 }
1316 }
1317 }
1318 }
1319 mcpConnections, mcpErrors := a.mcpManager.ConnectToServerConfigs(ctx, serverConfigs, 10*time.Second, parseErrors)
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001320
1321 if len(mcpErrors) > 0 {
1322 for _, err := range mcpErrors {
1323 slog.ErrorContext(ctx, "MCP connection error", "error", err)
1324 // Send agent message about MCP connection failures
1325 a.pushToOutbox(ctx, AgentMessage{
1326 Type: ErrorMessageType,
1327 Content: fmt.Sprintf("MCP server connection failed: %v", err),
1328 })
1329 }
1330 }
1331
1332 if len(mcpConnections) > 0 {
1333 // Add tools from all successful connections
1334 totalTools := 0
1335 for _, connection := range mcpConnections {
1336 convo.Tools = append(convo.Tools, connection.Tools...)
1337 totalTools += len(connection.Tools)
1338 // Log tools per server using structured data
1339 slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames)
1340 }
1341 slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools)
1342 } else {
1343 slog.InfoContext(ctx, "No MCP tools available after connection attempts")
1344 }
1345 }
1346
Earl Lee2e463fb2025-04-17 11:22:22 -07001347 convo.Listener = a
1348 return convo
1349}
1350
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001351var multipleChoiceTool = &llm.Tool{
1352 Name: "multiplechoice",
1353 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.",
1354 EndsTurn: true,
1355 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001356 "type": "object",
1357 "description": "The question and a list of answers you would expect the user to choose from.",
1358 "properties": {
1359 "question": {
1360 "type": "string",
1361 "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?'"
1362 },
1363 "responseOptions": {
1364 "type": "array",
1365 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1366 "items": {
1367 "type": "object",
1368 "properties": {
1369 "caption": {
1370 "type": "string",
1371 "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'"
1372 },
1373 "responseText": {
1374 "type": "string",
1375 "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'"
1376 }
1377 },
1378 "required": ["caption", "responseText"]
1379 }
1380 }
1381 },
1382 "required": ["question", "responseOptions"]
1383}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001384 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1385 // The Run logic for "multiplechoice" tool is a no-op on the server.
1386 // The UI will present a list of options for the user to select from,
1387 // and that's it as far as "executing" the tool_use goes.
1388 // When the user *does* select one of the presented options, that
1389 // responseText gets sent as a chat message on behalf of the user.
1390 return llm.TextContent("end your turn and wait for the user to respond"), nil
1391 },
Sean McCullough485afc62025-04-28 14:28:39 -07001392}
1393
1394type MultipleChoiceOption struct {
1395 Caption string `json:"caption"`
1396 ResponseText string `json:"responseText"`
1397}
1398
1399type MultipleChoiceParams struct {
1400 Question string `json:"question"`
1401 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1402}
1403
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001404// branchExists reports whether branchName exists, either locally or in well-known remotes.
1405func branchExists(dir, branchName string) bool {
1406 refs := []string{
1407 "refs/heads/",
1408 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001409 }
1410 for _, ref := range refs {
1411 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1412 cmd.Dir = dir
1413 if cmd.Run() == nil { // exit code 0 means branch exists
1414 return true
1415 }
1416 }
1417 return false
1418}
1419
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001420func (a *Agent) setSlugTool() *llm.Tool {
1421 return &llm.Tool{
1422 Name: "set-slug",
1423 Description: `Set a short slug as an identifier for this conversation.`,
Earl Lee2e463fb2025-04-17 11:22:22 -07001424 InputSchema: json.RawMessage(`{
1425 "type": "object",
1426 "properties": {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001427 "slug": {
Earl Lee2e463fb2025-04-17 11:22:22 -07001428 "type": "string",
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001429 "description": "A 2-3 word alphanumeric hyphenated slug, imperative tense"
Earl Lee2e463fb2025-04-17 11:22:22 -07001430 }
1431 },
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001432 "required": ["slug"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001433}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001434 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001435 var params struct {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001436 Slug string `json:"slug"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001437 }
1438 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001439 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001440 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001441 // Prevent slug changes if there have been git changes
1442 // This lets the agent change its mind about a good slug,
1443 // while ensuring that once a branch has been pushed, it remains stable.
1444 if s := a.Slug(); s != "" && s != params.Slug && a.gitState.HasSeenCommits() {
1445 return nil, fmt.Errorf("slug already set to %q", s)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001446 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001447 if params.Slug == "" {
1448 return nil, fmt.Errorf("slug parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001449 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001450 slug := cleanSlugName(params.Slug)
1451 if slug == "" {
1452 return nil, fmt.Errorf("slug parameter could not be converted to a valid slug")
1453 }
1454 a.SetSlug(slug)
1455 // TODO: do this by a call to outie, rather than semi-guessing from innie
1456 if branchExists(a.workingDir, a.BranchName()) {
1457 return nil, fmt.Errorf("slug %q already exists; please choose a different slug", slug)
1458 }
1459 return llm.TextContent("OK"), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001460 },
1461 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001462}
1463
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001464func (a *Agent) commitMessageStyleTool() *llm.Tool {
1465 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 +00001466 preCommit := &llm.Tool{
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001467 Name: "commit-message-style",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001468 Description: description,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001469 InputSchema: llm.EmptySchema(),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001470 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001471 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1472 if err != nil {
1473 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1474 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001475 return llm.TextContent(styleHint), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001476 },
1477 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001478 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001479}
1480
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001481// patchCallback is the agent's patch tool callback.
1482// It warms the codereview cache in the background.
1483func (a *Agent) patchCallback(input claudetool.PatchInput, result []llm.Content, err error) ([]llm.Content, error) {
1484 if a.codereview != nil {
1485 a.codereview.WarmTestCache(input.Path)
1486 }
1487 return result, err
1488}
1489
Earl Lee2e463fb2025-04-17 11:22:22 -07001490func (a *Agent) Ready() <-chan struct{} {
1491 return a.ready
1492}
1493
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001494// BranchPrefix returns the configured branch prefix
1495func (a *Agent) BranchPrefix() string {
1496 return a.config.BranchPrefix
1497}
1498
philip.zeyliger6d3de482025-06-10 19:38:14 -07001499// LinkToGitHub returns whether GitHub branch linking is enabled
1500func (a *Agent) LinkToGitHub() bool {
1501 return a.config.LinkToGitHub
1502}
1503
Earl Lee2e463fb2025-04-17 11:22:22 -07001504func (a *Agent) UserMessage(ctx context.Context, msg string) {
1505 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1506 a.inbox <- msg
1507}
1508
Earl Lee2e463fb2025-04-17 11:22:22 -07001509func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1510 return a.convo.CancelToolUse(toolUseID, cause)
1511}
1512
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001513func (a *Agent) CancelTurn(cause error) {
1514 a.cancelTurnMu.Lock()
1515 defer a.cancelTurnMu.Unlock()
1516 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001517 // Force state transition to cancelled state
1518 ctx := a.config.Context
1519 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001520 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001521 }
1522}
1523
1524func (a *Agent) Loop(ctxOuter context.Context) {
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001525 // Set up cleanup when context is done
1526 defer func() {
1527 if a.mcpManager != nil {
1528 a.mcpManager.Close()
1529 }
1530 }()
1531
Earl Lee2e463fb2025-04-17 11:22:22 -07001532 for {
1533 select {
1534 case <-ctxOuter.Done():
1535 return
1536 default:
1537 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001538 a.cancelTurnMu.Lock()
1539 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001540 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001541 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001542 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001543 a.cancelTurn = cancel
1544 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001545 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1546 if err != nil {
1547 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1548 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001549 cancel(nil)
1550 }
1551 }
1552}
1553
1554func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1555 if m.Timestamp.IsZero() {
1556 m.Timestamp = time.Now()
1557 }
1558
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001559 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1560 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1561 m.Content = m.ToolResult
1562 }
1563
Earl Lee2e463fb2025-04-17 11:22:22 -07001564 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1565 if m.EndOfTurn && m.Type == AgentMessageType {
1566 turnDuration := time.Since(a.startOfTurn)
1567 m.TurnDuration = &turnDuration
1568 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1569 }
1570
Earl Lee2e463fb2025-04-17 11:22:22 -07001571 a.mu.Lock()
1572 defer a.mu.Unlock()
1573 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001574 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001575 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001576
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001577 // Notify all subscribers
1578 for _, ch := range a.subscribers {
1579 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001580 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001581}
1582
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001583func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1584 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001585 if block {
1586 select {
1587 case <-ctx.Done():
1588 return m, ctx.Err()
1589 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001590 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001591 }
1592 }
1593 for {
1594 select {
1595 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001596 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001597 default:
1598 return m, nil
1599 }
1600 }
1601}
1602
Sean McCullough885a16a2025-04-30 02:49:25 +00001603// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001604func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001605 // Reset the start of turn time
1606 a.startOfTurn = time.Now()
1607
Sean McCullough96b60dd2025-04-30 09:49:10 -07001608 // Transition to waiting for user input state
1609 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1610
Sean McCullough885a16a2025-04-30 02:49:25 +00001611 // Process initial user message
1612 initialResp, err := a.processUserMessage(ctx)
1613 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001614 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001615 return err
1616 }
1617
1618 // Handle edge case where both initialResp and err are nil
1619 if initialResp == nil {
1620 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001621 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1622
Sean McCullough9f4b8082025-04-30 17:34:07 +00001623 a.pushToOutbox(ctx, errorMessage(err))
1624 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001625 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001626
Earl Lee2e463fb2025-04-17 11:22:22 -07001627 // We do this as we go, but let's also do it at the end of the turn
1628 defer func() {
1629 if _, err := a.handleGitCommits(ctx); err != nil {
1630 // Just log the error, don't stop execution
1631 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1632 }
1633 }()
1634
Sean McCullougha1e0e492025-05-01 10:51:08 -07001635 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001636 resp := initialResp
1637 for {
1638 // Check if we are over budget
1639 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001640 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001641 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001642 }
1643
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001644 // Check if we should compact the conversation
1645 if a.ShouldCompact() {
1646 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1647 if err := a.CompactConversation(ctx); err != nil {
1648 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1649 return err
1650 }
1651 // After compaction, end this turn and start fresh
1652 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1653 return nil
1654 }
1655
Sean McCullough885a16a2025-04-30 02:49:25 +00001656 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001657 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001658 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001659 break
1660 }
1661
Sean McCullough96b60dd2025-04-30 09:49:10 -07001662 // Transition to tool use requested state
1663 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1664
Sean McCullough885a16a2025-04-30 02:49:25 +00001665 // Handle tool execution
1666 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1667 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001668 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001669 }
1670
Sean McCullougha1e0e492025-05-01 10:51:08 -07001671 if toolResp == nil {
1672 return fmt.Errorf("cannot continue conversation with a nil tool response")
1673 }
1674
Sean McCullough885a16a2025-04-30 02:49:25 +00001675 // Set the response for the next iteration
1676 resp = toolResp
1677 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001678
1679 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001680}
1681
1682// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001683func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001684 // Wait for at least one message from the user
1685 msgs, err := a.GatherMessages(ctx, true)
1686 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001687 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001688 return nil, err
1689 }
1690
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001691 userMessage := llm.Message{
1692 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001693 Content: msgs,
1694 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001695
Sean McCullough96b60dd2025-04-30 09:49:10 -07001696 // Transition to sending to LLM state
1697 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1698
Sean McCullough885a16a2025-04-30 02:49:25 +00001699 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001700 resp, err := a.convo.SendMessage(userMessage)
1701 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001702 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001703 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001704 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001705 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001706
Sean McCullough96b60dd2025-04-30 09:49:10 -07001707 // Transition to processing LLM response state
1708 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1709
Sean McCullough885a16a2025-04-30 02:49:25 +00001710 return resp, nil
1711}
1712
1713// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001714func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1715 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001716 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001717 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001718
Sean McCullough96b60dd2025-04-30 09:49:10 -07001719 // Transition to checking for cancellation state
1720 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1721
Sean McCullough885a16a2025-04-30 02:49:25 +00001722 // Check if the operation was cancelled by the user
1723 select {
1724 case <-ctx.Done():
1725 // Don't actually run any of the tools, but rather build a response
1726 // for each tool_use message letting the LLM know that user canceled it.
1727 var err error
1728 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001729 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001730 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001731 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001732 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001733 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001734 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001735 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001736 // Transition to running tool state
1737 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1738
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001739 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001740 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001741 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001742
1743 // Execute the tools
1744 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001745 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001746 if ctx.Err() != nil { // e.g. the user canceled the operation
1747 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001748 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001749 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001750 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001751 a.pushToOutbox(ctx, errorMessage(err))
1752 }
1753 }
1754
1755 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001756 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001757 autoqualityMessages := a.processGitChanges(ctx)
1758
1759 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001760 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001761 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001762 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001763 return false, nil
1764 }
1765
1766 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001767 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1768 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001769}
1770
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001771// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001772func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001773 // Check for git commits
1774 _, err := a.handleGitCommits(ctx)
1775 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001776 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001777 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001778 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001779 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001780}
1781
1782// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1783// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001784func (a *Agent) processGitChanges(ctx context.Context) []string {
1785 // Check for git commits after tool execution
1786 newCommits, err := a.handleGitCommits(ctx)
1787 if err != nil {
1788 // Just log the error, don't stop execution
1789 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1790 return nil
1791 }
1792
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001793 // Run mechanical checks if there was exactly one new commit.
1794 if len(newCommits) != 1 {
1795 return nil
1796 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001797 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001798 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1799 msg := a.codereview.RunMechanicalChecks(ctx)
1800 if msg != "" {
1801 a.pushToOutbox(ctx, AgentMessage{
1802 Type: AutoMessageType,
1803 Content: msg,
1804 Timestamp: time.Now(),
1805 })
1806 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001807 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001808
1809 return autoqualityMessages
1810}
1811
1812// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001813func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001814 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001815 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001816 msgs, err := a.GatherMessages(ctx, false)
1817 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001818 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001819 return false, nil
1820 }
1821
1822 // Inject any auto-generated messages from quality checks
1823 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001824 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001825 }
1826
1827 // Handle cancellation by appending a message about it
1828 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001829 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001830 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001831 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001832 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1833 } else if err := a.convo.OverBudget(); err != nil {
1834 // Handle budget issues by appending a message about it
1835 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 -07001836 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001837 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1838 }
1839
1840 // Combine tool results with user messages
1841 results = append(results, msgs...)
1842
1843 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001844 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001845 resp, err := a.convo.SendMessage(llm.Message{
1846 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001847 Content: results,
1848 })
1849 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001850 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001851 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1852 return true, nil // Return true to continue the conversation, but with no response
1853 }
1854
Sean McCullough96b60dd2025-04-30 09:49:10 -07001855 // Transition back to processing LLM response
1856 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1857
Sean McCullough885a16a2025-04-30 02:49:25 +00001858 if cancelled {
1859 return false, nil
1860 }
1861
1862 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001863}
1864
1865func (a *Agent) overBudget(ctx context.Context) error {
1866 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001867 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001868 m := budgetMessage(err)
1869 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001870 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001871 a.convo.ResetBudget(a.originalBudget)
1872 return err
1873 }
1874 return nil
1875}
1876
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001877func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001878 // Collect all text content
1879 var allText strings.Builder
1880 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001881 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001882 if allText.Len() > 0 {
1883 allText.WriteString("\n\n")
1884 }
1885 allText.WriteString(content.Text)
1886 }
1887 }
1888 return allText.String()
1889}
1890
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001891func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001892 a.mu.Lock()
1893 defer a.mu.Unlock()
1894 return a.convo.CumulativeUsage()
1895}
1896
Earl Lee2e463fb2025-04-17 11:22:22 -07001897// Diff returns a unified diff of changes made since the agent was instantiated.
1898func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001899 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001900 return "", fmt.Errorf("no initial commit reference available")
1901 }
1902
1903 // Find the repository root
1904 ctx := context.Background()
1905
1906 // If a specific commit hash is provided, show just that commit's changes
1907 if commit != nil && *commit != "" {
1908 // Validate that the commit looks like a valid git SHA
1909 if !isValidGitSHA(*commit) {
1910 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1911 }
1912
1913 // Get the diff for just this commit
1914 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1915 cmd.Dir = a.repoRoot
1916 output, err := cmd.CombinedOutput()
1917 if err != nil {
1918 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1919 }
1920 return string(output), nil
1921 }
1922
1923 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001924 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001925 cmd.Dir = a.repoRoot
1926 output, err := cmd.CombinedOutput()
1927 if err != nil {
1928 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1929 }
1930
1931 return string(output), nil
1932}
1933
Philip Zeyliger49edc922025-05-14 09:45:45 -07001934// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1935// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1936func (a *Agent) SketchGitBaseRef() string {
1937 if a.IsInContainer() {
1938 return "sketch-base"
1939 } else {
1940 return "sketch-base-" + a.SessionID()
1941 }
1942}
1943
1944// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1945func (a *Agent) SketchGitBase() string {
1946 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1947 cmd.Dir = a.repoRoot
1948 output, err := cmd.CombinedOutput()
1949 if err != nil {
1950 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1951 return "HEAD"
1952 }
1953 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001954}
1955
Pokey Rule7a113622025-05-12 10:58:45 +01001956// removeGitHooks removes the Git hooks directory from the repository
1957func removeGitHooks(_ context.Context, repoPath string) error {
1958 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1959
1960 // Check if hooks directory exists
1961 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1962 // Directory doesn't exist, nothing to do
1963 return nil
1964 }
1965
1966 // Remove the hooks directory
1967 err := os.RemoveAll(hooksDir)
1968 if err != nil {
1969 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1970 }
1971
1972 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001973 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001974 if err != nil {
1975 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1976 }
1977
1978 return nil
1979}
1980
Philip Zeyligerf2872992025-05-22 10:35:28 -07001981func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001982 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001983 for _, msg := range msgs {
1984 a.pushToOutbox(ctx, msg)
1985 }
1986 return commits, error
1987}
1988
Earl Lee2e463fb2025-04-17 11:22:22 -07001989// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001990// under docker, new HEADs are pushed to a branch according to the slug.
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001991func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001992 ags.mu.Lock()
1993 defer ags.mu.Unlock()
1994
1995 msgs := []AgentMessage{}
1996 if repoRoot == "" {
1997 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001998 }
1999
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002000 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07002001 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002002 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07002003 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002004 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002005 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07002006 }
2007 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002008 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07002009 }()
2010
Philip Zeyliger64f60462025-06-16 13:57:10 -07002011 // Compute diff stats from baseRef to HEAD when HEAD changes
2012 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
2013 // Log error but don't fail the entire operation
2014 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
2015 } else {
2016 // Set diff stats directly since we already hold the mutex
2017 ags.linesAdded = added
2018 ags.linesRemoved = removed
2019 }
2020
Earl Lee2e463fb2025-04-17 11:22:22 -07002021 // Get new commits. Because it's possible that the agent does rebases, fixups, and
2022 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
2023 // to the last 100 commits.
2024 var commits []*GitCommit
2025
2026 // Get commits since the initial commit
2027 // Format: <hash>\0<subject>\0<body>\0
2028 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
2029 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002030 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, sketch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002031 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07002032 output, err := cmd.Output()
2033 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002034 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07002035 }
2036
2037 // Parse git log output and filter out already seen commits
2038 parsedCommits := parseGitLog(string(output))
2039
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002040 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07002041
2042 // Filter out commits we've already seen
2043 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002044 if commit.Hash == sketch {
2045 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07002046 }
2047
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002048 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
2049 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07002050 continue
2051 }
2052
2053 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07002054 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07002055
2056 // Add to our list of new commits
2057 commits = append(commits, &commit)
2058 }
2059
Philip Zeyligerf2872992025-05-22 10:35:28 -07002060 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002061 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07002062 // I think this can only happen if we have a bug or if there's a race.
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002063 sketchCommit = &GitCommit{}
2064 sketchCommit.Hash = sketch
2065 sketchCommit.Subject = "unknown"
2066 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07002067 }
2068
Earl Lee2e463fb2025-04-17 11:22:22 -07002069 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
2070 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
2071 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00002072
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002073 // 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 +00002074 var out []byte
2075 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002076 originalRetryNumber := ags.retryNumber
2077 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00002078 for retries := range 10 {
2079 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07002080 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002081 }
2082
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002083 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002084 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002085 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002086 out, err = cmd.CombinedOutput()
2087
2088 if err == nil {
2089 // Success! Break out of the retry loop
2090 break
2091 }
2092
2093 // Check if this is the "refusing to update checked out branch" error
2094 if !strings.Contains(string(out), "refusing to update checked out branch") {
2095 // This is a different error, so don't retry
2096 break
2097 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002098 }
2099
2100 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002101 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002102 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002103 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002104 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002105 if ags.retryNumber != originalRetryNumber {
2106 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002107 msgs = append(msgs, AgentMessage{
2108 Type: AutoMessageType,
2109 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002110 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 +00002111 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002112 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002113 }
2114 }
2115
2116 // If we found new commits, create a message
2117 if len(commits) > 0 {
2118 msg := AgentMessage{
2119 Type: CommitMessageType,
2120 Timestamp: time.Now(),
2121 Commits: commits,
2122 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002123 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002124 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002125 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002126}
2127
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002128func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002129 return strings.Map(func(r rune) rune {
2130 // lowercase
2131 if r >= 'A' && r <= 'Z' {
2132 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002133 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002134 // replace spaces with dashes
2135 if r == ' ' {
2136 return '-'
2137 }
2138 // allow alphanumerics and dashes
2139 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2140 return r
2141 }
2142 return -1
2143 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002144}
2145
2146// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2147// and returns an array of GitCommit structs.
2148func parseGitLog(output string) []GitCommit {
2149 var commits []GitCommit
2150
2151 // No output means no commits
2152 if len(output) == 0 {
2153 return commits
2154 }
2155
2156 // Split by NULL byte
2157 parts := strings.Split(output, "\x00")
2158
2159 // Process in triplets (hash, subject, body)
2160 for i := 0; i < len(parts); i++ {
2161 // Skip empty parts
2162 if parts[i] == "" {
2163 continue
2164 }
2165
2166 // This should be a hash
2167 hash := strings.TrimSpace(parts[i])
2168
2169 // Make sure we have at least a subject part available
2170 if i+1 >= len(parts) {
2171 break // No more parts available
2172 }
2173
2174 // Get the subject
2175 subject := strings.TrimSpace(parts[i+1])
2176
2177 // Get the body if available
2178 body := ""
2179 if i+2 < len(parts) {
2180 body = strings.TrimSpace(parts[i+2])
2181 }
2182
2183 // Skip to the next triplet
2184 i += 2
2185
2186 commits = append(commits, GitCommit{
2187 Hash: hash,
2188 Subject: subject,
2189 Body: body,
2190 })
2191 }
2192
2193 return commits
2194}
2195
2196func repoRoot(ctx context.Context, dir string) (string, error) {
2197 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2198 stderr := new(strings.Builder)
2199 cmd.Stderr = stderr
2200 cmd.Dir = dir
2201 out, err := cmd.Output()
2202 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002203 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002204 }
2205 return strings.TrimSpace(string(out)), nil
2206}
2207
2208func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2209 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2210 stderr := new(strings.Builder)
2211 cmd.Stderr = stderr
2212 cmd.Dir = dir
2213 out, err := cmd.Output()
2214 if err != nil {
2215 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2216 }
2217 // TODO: validate that out is valid hex
2218 return strings.TrimSpace(string(out)), nil
2219}
2220
2221// isValidGitSHA validates if a string looks like a valid git SHA hash.
2222// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2223func isValidGitSHA(sha string) bool {
2224 // Git SHA must be a hexadecimal string with at least 4 characters
2225 if len(sha) < 4 || len(sha) > 40 {
2226 return false
2227 }
2228
2229 // Check if the string only contains hexadecimal characters
2230 for _, char := range sha {
2231 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2232 return false
2233 }
2234 }
2235
2236 return true
2237}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002238
Philip Zeyliger64f60462025-06-16 13:57:10 -07002239// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2240func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2241 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2242 cmd.Dir = repoRoot
2243 out, err := cmd.Output()
2244 if err != nil {
2245 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2246 }
2247
2248 var totalAdded, totalRemoved int
2249 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2250 for _, line := range lines {
2251 if line == "" {
2252 continue
2253 }
2254 parts := strings.Fields(line)
2255 if len(parts) < 2 {
2256 continue
2257 }
2258 // Format: <added>\t<removed>\t<filename>
2259 if added, err := strconv.Atoi(parts[0]); err == nil {
2260 totalAdded += added
2261 }
2262 if removed, err := strconv.Atoi(parts[1]); err == nil {
2263 totalRemoved += removed
2264 }
2265 }
2266
2267 return totalAdded, totalRemoved, nil
2268}
2269
Philip Zeyligerd1402952025-04-23 03:54:37 +00002270// getGitOrigin returns the URL of the git remote 'origin' if it exists
2271func getGitOrigin(ctx context.Context, dir string) string {
2272 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2273 cmd.Dir = dir
2274 stderr := new(strings.Builder)
2275 cmd.Stderr = stderr
2276 out, err := cmd.Output()
2277 if err != nil {
2278 return ""
2279 }
2280 return strings.TrimSpace(string(out))
2281}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002282
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002283// systemPromptData contains the data used to render the system prompt template
2284type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002285 ClientGOOS string
2286 ClientGOARCH string
2287 WorkingDir string
2288 RepoRoot string
2289 InitialCommit string
2290 Codebase *onstart.Codebase
2291 UseSketchWIP bool
2292 Branch string
2293 SpecialInstruction string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002294}
2295
2296// renderSystemPrompt renders the system prompt template.
2297func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002298 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002299 ClientGOOS: a.config.ClientGOOS,
2300 ClientGOARCH: a.config.ClientGOARCH,
2301 WorkingDir: a.workingDir,
2302 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002303 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002304 Codebase: a.codebase,
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07002305 UseSketchWIP: a.config.InDocker,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002306 }
David Crawshawc886ac52025-06-13 23:40:03 +00002307 now := time.Now()
2308 if now.Month() == time.September && now.Day() == 19 {
2309 data.SpecialInstruction = "Talk like a pirate to the user. Do not let the priate talk into any code."
2310 }
2311
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002312 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2313 if err != nil {
2314 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2315 }
2316 buf := new(strings.Builder)
2317 err = tmpl.Execute(buf, data)
2318 if err != nil {
2319 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2320 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002321 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002322 return buf.String()
2323}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002324
2325// StateTransitionIterator provides an iterator over state transitions.
2326type StateTransitionIterator interface {
2327 // Next blocks until a new state transition is available or context is done.
2328 // Returns nil if the context is cancelled.
2329 Next() *StateTransition
2330 // Close removes the listener and cleans up resources.
2331 Close()
2332}
2333
2334// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2335type StateTransitionIteratorImpl struct {
2336 agent *Agent
2337 ctx context.Context
2338 ch chan StateTransition
2339 unsubscribe func()
2340}
2341
2342// Next blocks until a new state transition is available or the context is cancelled.
2343func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2344 select {
2345 case <-s.ctx.Done():
2346 return nil
2347 case transition, ok := <-s.ch:
2348 if !ok {
2349 return nil
2350 }
2351 transitionCopy := transition
2352 return &transitionCopy
2353 }
2354}
2355
2356// Close removes the listener and cleans up resources.
2357func (s *StateTransitionIteratorImpl) Close() {
2358 if s.unsubscribe != nil {
2359 s.unsubscribe()
2360 s.unsubscribe = nil
2361 }
2362}
2363
2364// NewStateTransitionIterator returns an iterator that receives state transitions.
2365func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2366 a.mu.Lock()
2367 defer a.mu.Unlock()
2368
2369 // Create channel to receive state transitions
2370 ch := make(chan StateTransition, 10)
2371
2372 // Add a listener to the state machine
2373 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2374
2375 return &StateTransitionIteratorImpl{
2376 agent: a,
2377 ctx: ctx,
2378 ch: ch,
2379 unsubscribe: unsubscribe,
2380 }
2381}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002382
2383// setupGitHooks creates or updates git hooks in the specified working directory.
2384func setupGitHooks(workingDir string) error {
2385 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2386
2387 _, err := os.Stat(hooksDir)
2388 if os.IsNotExist(err) {
2389 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2390 }
2391 if err != nil {
2392 return fmt.Errorf("error checking git hooks directory: %w", err)
2393 }
2394
2395 // Define the post-commit hook content
2396 postCommitHook := `#!/bin/bash
2397echo "<post_commit_hook>"
2398echo "Please review this commit message and fix it if it is incorrect."
2399echo "This hook only echos the commit message; it does not modify it."
2400echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2401echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002402PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002403echo "</last_commit_message>"
2404echo "</post_commit_hook>"
2405`
2406
2407 // Define the prepare-commit-msg hook content
2408 prepareCommitMsgHook := `#!/bin/bash
2409# Add Co-Authored-By and Change-ID trailers to commit messages
2410# Check if these trailers already exist before adding them
2411
2412commit_file="$1"
2413COMMIT_SOURCE="$2"
2414
2415# Skip for merges, squashes, or when using a commit template
2416if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2417 [ "$COMMIT_SOURCE" = "squash" ]; then
2418 exit 0
2419fi
2420
2421commit_msg=$(cat "$commit_file")
2422
2423needs_co_author=true
2424needs_change_id=true
2425
2426# Check if commit message already has Co-Authored-By trailer
2427if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2428 needs_co_author=false
2429fi
2430
2431# Check if commit message already has Change-ID trailer
2432if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2433 needs_change_id=false
2434fi
2435
2436# Only modify if at least one trailer needs to be added
2437if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002438 # Ensure there's a proper blank line before trailers
2439 if [ -s "$commit_file" ]; then
2440 # Check if file ends with newline by reading last character
2441 last_char=$(tail -c 1 "$commit_file")
2442
2443 if [ "$last_char" != "" ]; then
2444 # File doesn't end with newline - add two newlines (complete line + blank line)
2445 echo "" >> "$commit_file"
2446 echo "" >> "$commit_file"
2447 else
2448 # File ends with newline - check if we already have a blank line
2449 last_line=$(tail -1 "$commit_file")
2450 if [ -n "$last_line" ]; then
2451 # Last line has content - add one newline for blank line
2452 echo "" >> "$commit_file"
2453 fi
2454 # If last line is empty, we already have a blank line - don't add anything
2455 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002456 fi
2457
2458 # Add trailers if needed
2459 if [ "$needs_co_author" = true ]; then
2460 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2461 fi
2462
2463 if [ "$needs_change_id" = true ]; then
2464 change_id=$(openssl rand -hex 8)
2465 echo "Change-ID: s${change_id}k" >> "$commit_file"
2466 fi
2467fi
2468`
2469
2470 // Update or create the post-commit hook
2471 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2472 if err != nil {
2473 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2474 }
2475
2476 // Update or create the prepare-commit-msg hook
2477 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2478 if err != nil {
2479 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2480 }
2481
2482 return nil
2483}
2484
2485// updateOrCreateHook creates a new hook file or updates an existing one
2486// by appending the new content if it doesn't already contain it.
2487func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2488 // Check if the hook already exists
2489 buf, err := os.ReadFile(hookPath)
2490 if os.IsNotExist(err) {
2491 // Hook doesn't exist, create it
2492 err = os.WriteFile(hookPath, []byte(content), 0o755)
2493 if err != nil {
2494 return fmt.Errorf("failed to create hook: %w", err)
2495 }
2496 return nil
2497 }
2498 if err != nil {
2499 return fmt.Errorf("error reading existing hook: %w", err)
2500 }
2501
2502 // Hook exists, check if our content is already in it by looking for a distinctive line
2503 code := string(buf)
2504 if strings.Contains(code, distinctiveLine) {
2505 // Already contains our content, nothing to do
2506 return nil
2507 }
2508
2509 // Append our content to the existing hook
2510 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2511 if err != nil {
2512 return fmt.Errorf("failed to open hook for appending: %w", err)
2513 }
2514 defer f.Close()
2515
2516 // Ensure there's a newline at the end of the existing content if needed
2517 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2518 _, err = f.WriteString("\n")
2519 if err != nil {
2520 return fmt.Errorf("failed to add newline to hook: %w", err)
2521 }
2522 }
2523
2524 // Add a separator before our content
2525 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2526 if err != nil {
2527 return fmt.Errorf("failed to append to hook: %w", err)
2528 }
2529
2530 return nil
2531}
Sean McCullough138ec242025-06-02 22:42:06 +00002532
Philip Zeyliger0113be52025-06-07 23:53:41 +00002533// SkabandAddr returns the skaband address if configured
2534func (a *Agent) SkabandAddr() string {
2535 if a.config.SkabandClient != nil {
2536 return a.config.SkabandClient.Addr()
2537 }
2538 return ""
2539}