blob: 82d823ed728230284cbb8c776c0195d1b6d1fdc7 [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"
Philip Zeyliger5f26a342025-07-04 01:30:29 +000033 "tailscale.com/portlist"
Earl Lee2e463fb2025-04-17 11:22:22 -070034)
35
36const (
37 userCancelMessage = "user requested agent to stop handling responses"
38)
39
Philip Zeyligerb7c58752025-05-01 10:10:17 -070040type MessageIterator interface {
41 // Next blocks until the next message is available. It may
42 // return nil if the underlying iterator context is done.
43 Next() *AgentMessage
44 Close()
45}
46
Earl Lee2e463fb2025-04-17 11:22:22 -070047type CodingAgent interface {
48 // Init initializes an agent inside a docker container.
49 Init(AgentInit) error
50
51 // Ready returns a channel closed after Init successfully called.
52 Ready() <-chan struct{}
53
54 // URL reports the HTTP URL of this agent.
55 URL() string
56
57 // UserMessage enqueues a message to the agent and returns immediately.
58 UserMessage(ctx context.Context, msg string)
59
Philip Zeyligerb7c58752025-05-01 10:10:17 -070060 // Returns an iterator that finishes when the context is done and
61 // starts with the given message index.
62 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070063
Philip Zeyligereab12de2025-05-14 02:35:53 +000064 // Returns an iterator that notifies of state transitions until the context is done.
65 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
66
Earl Lee2e463fb2025-04-17 11:22:22 -070067 // Loop begins the agent loop returns only when ctx is cancelled.
68 Loop(ctx context.Context)
69
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000070 // BranchPrefix returns the configured branch prefix
71 BranchPrefix() string
72
philip.zeyliger6d3de482025-06-10 19:38:14 -070073 // LinkToGitHub returns whether GitHub branch linking is enabled
74 LinkToGitHub() bool
75
Sean McCulloughedc88dc2025-04-30 02:55:01 +000076 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070077
78 CancelToolUse(toolUseID string, cause error) error
79
80 // Returns a subset of the agent's message history.
81 Messages(start int, end int) []AgentMessage
82
83 // Returns the current number of messages in the history
84 MessageCount() int
85
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070086 TotalUsage() conversation.CumulativeUsage
87 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070088
Earl Lee2e463fb2025-04-17 11:22:22 -070089 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000090 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070091
92 // Diff returns a unified diff of changes made since the agent was instantiated.
93 // If commit is non-nil, it shows the diff for just that specific commit.
94 Diff(commit *string) (string, error)
95
Philip Zeyliger49edc922025-05-14 09:45:45 -070096 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
97 // starts out as the commit where sketch started, but a user can move it if need
98 // be, for example in the case of a rebase. It is stored as a git tag.
99 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700100
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000101 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
102 // (Typically, this is "sketch-base")
103 SketchGitBaseRef() string
104
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700105 // Slug returns the slug identifier for this session.
106 Slug() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700107
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000108 // BranchName returns the git branch name for the conversation.
109 BranchName() string
110
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700111 // IncrementRetryNumber increments the retry number for branch naming conflicts.
112 IncrementRetryNumber()
113
Earl Lee2e463fb2025-04-17 11:22:22 -0700114 // OS returns the operating system of the client.
115 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000116
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000117 // SessionID returns the unique session identifier.
118 SessionID() string
119
philip.zeyliger8773e682025-06-11 21:36:21 -0700120 // SSHConnectionString returns the SSH connection string for the container.
121 SSHConnectionString() string
122
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000123 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700124 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000125
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000126 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
127 OutstandingLLMCallCount() int
128
129 // OutstandingToolCalls returns the names of outstanding tool calls.
130 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000131 OutsideOS() string
132 OutsideHostname() string
133 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000134 GitOrigin() string
Philip Zeyliger64f60462025-06-16 13:57:10 -0700135
bankseancad67b02025-06-27 21:57:05 +0000136 // GitUsername returns the git user name from the agent config.
137 GitUsername() string
138
Philip Zeyliger64f60462025-06-16 13:57:10 -0700139 // DiffStats returns the number of lines added and removed from sketch-base to HEAD
140 DiffStats() (int, int)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000141 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
142 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700143
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700144 // IsInContainer returns true if the agent is running in a container
145 IsInContainer() bool
146 // FirstMessageIndex returns the index of the first message in the current conversation
147 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700148
149 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700150 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
151 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700152
153 // CompactConversation compacts the current conversation by generating a summary
154 // and restarting the conversation with that summary as the initial context
155 CompactConversation(ctx context.Context) error
Philip Zeyligerda623b52025-07-04 01:12:38 +0000156
Philip Zeyliger0113be52025-06-07 23:53:41 +0000157 // SkabandAddr returns the skaband address if configured
158 SkabandAddr() string
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000159
160 // GetPorts returns the cached list of open TCP ports
161 GetPorts() []portlist.Port
Earl Lee2e463fb2025-04-17 11:22:22 -0700162}
163
164type CodingAgentMessageType string
165
166const (
167 UserMessageType CodingAgentMessageType = "user"
168 AgentMessageType CodingAgentMessageType = "agent"
169 ErrorMessageType CodingAgentMessageType = "error"
170 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
171 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700172 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
173 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
174 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000175 PortMessageType CodingAgentMessageType = "port" // for port monitoring events
Earl Lee2e463fb2025-04-17 11:22:22 -0700176
177 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
178)
179
180type AgentMessage struct {
181 Type CodingAgentMessageType `json:"type"`
182 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
183 EndOfTurn bool `json:"end_of_turn"`
184
185 Content string `json:"content"`
186 ToolName string `json:"tool_name,omitempty"`
187 ToolInput string `json:"input,omitempty"`
188 ToolResult string `json:"tool_result,omitempty"`
189 ToolError bool `json:"tool_error,omitempty"`
190 ToolCallId string `json:"tool_call_id,omitempty"`
191
192 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
193 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
194
Sean McCulloughd9f13372025-04-21 15:08:49 -0700195 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
196 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
197
Earl Lee2e463fb2025-04-17 11:22:22 -0700198 // Commits is a list of git commits for a commit message
199 Commits []*GitCommit `json:"commits,omitempty"`
200
201 Timestamp time.Time `json:"timestamp"`
202 ConversationID string `json:"conversation_id"`
203 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700204 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700205
206 // Message timing information
207 StartTime *time.Time `json:"start_time,omitempty"`
208 EndTime *time.Time `json:"end_time,omitempty"`
209 Elapsed *time.Duration `json:"elapsed,omitempty"`
210
211 // Turn duration - the time taken for a complete agent turn
212 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
213
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000214 // HideOutput indicates that this message should not be rendered in the UI.
215 // This is useful for subconversations that generate output that shouldn't be shown to the user.
216 HideOutput bool `json:"hide_output,omitempty"`
217
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700218 // TodoContent contains the agent's todo file content when it has changed
219 TodoContent *string `json:"todo_content,omitempty"`
220
Earl Lee2e463fb2025-04-17 11:22:22 -0700221 Idx int `json:"idx"`
222}
223
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000224// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700225func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700226 if convo == nil {
227 m.ConversationID = ""
228 m.ParentConversationID = nil
229 return
230 }
231 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000232 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700233 if convo.Parent != nil {
234 m.ParentConversationID = &convo.Parent.ID
235 }
236}
237
Earl Lee2e463fb2025-04-17 11:22:22 -0700238// GitCommit represents a single git commit for a commit message
239type GitCommit struct {
240 Hash string `json:"hash"` // Full commit hash
241 Subject string `json:"subject"` // Commit subject line
242 Body string `json:"body"` // Full commit message body
243 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
244}
245
246// ToolCall represents a single tool call within an agent message
247type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700248 Name string `json:"name"`
249 Input string `json:"input"`
250 ToolCallId string `json:"tool_call_id"`
251 ResultMessage *AgentMessage `json:"result_message,omitempty"`
252 Args string `json:"args,omitempty"`
253 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700254}
255
256func (a *AgentMessage) Attr() slog.Attr {
257 var attrs []any = []any{
258 slog.String("type", string(a.Type)),
259 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700260 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700261 if a.EndOfTurn {
262 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
263 }
264 if a.Content != "" {
265 attrs = append(attrs, slog.String("content", a.Content))
266 }
267 if a.ToolName != "" {
268 attrs = append(attrs, slog.String("tool_name", a.ToolName))
269 }
270 if a.ToolInput != "" {
271 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
272 }
273 if a.Elapsed != nil {
274 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
275 }
276 if a.TurnDuration != nil {
277 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
278 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700279 if len(a.ToolResult) > 0 {
280 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700281 }
282 if a.ToolError {
283 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
284 }
285 if len(a.ToolCalls) > 0 {
286 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
287 for i, tc := range a.ToolCalls {
288 toolCallAttrs = append(toolCallAttrs, slog.Group(
289 fmt.Sprintf("tool_call_%d", i),
290 slog.String("name", tc.Name),
291 slog.String("input", tc.Input),
292 ))
293 }
294 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
295 }
296 if a.ConversationID != "" {
297 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
298 }
299 if a.ParentConversationID != nil {
300 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
301 }
302 if a.Usage != nil && !a.Usage.IsZero() {
303 attrs = append(attrs, a.Usage.Attr())
304 }
305 // TODO: timestamp, convo ids, idx?
306 return slog.Group("agent_message", attrs...)
307}
308
309func errorMessage(err error) AgentMessage {
310 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
311 if os.Getenv(("DEBUG")) == "1" {
312 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
313 }
314
315 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
316}
317
318func budgetMessage(err error) AgentMessage {
319 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
320}
321
322// ConvoInterface defines the interface for conversation interactions
323type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700324 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700325 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700326 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700327 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700328 SendMessage(message llm.Message) (*llm.Response, error)
329 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700330 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000331 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700332 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700333 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700334 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700335}
336
Philip Zeyligerf2872992025-05-22 10:35:28 -0700337// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700338// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700339// any time we notice we need to.
340type AgentGitState struct {
341 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700342 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700343 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000344 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700345 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700346 slug string // Human-readable session identifier
347 retryNumber int // Number to append when branch conflicts occur
Philip Zeyliger64f60462025-06-16 13:57:10 -0700348 linesAdded int // Lines added from sketch-base to HEAD
349 linesRemoved int // Lines removed from sketch-base to HEAD
Philip Zeyligerf2872992025-05-22 10:35:28 -0700350}
351
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700352func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700353 ags.mu.Lock()
354 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700355 if ags.slug != slug {
356 ags.retryNumber = 0
357 }
358 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700359}
360
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700361func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700362 ags.mu.Lock()
363 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700364 return ags.slug
365}
366
367func (ags *AgentGitState) IncrementRetryNumber() {
368 ags.mu.Lock()
369 defer ags.mu.Unlock()
370 ags.retryNumber++
371}
372
Philip Zeyliger64f60462025-06-16 13:57:10 -0700373func (ags *AgentGitState) DiffStats() (int, int) {
374 ags.mu.Lock()
375 defer ags.mu.Unlock()
376 return ags.linesAdded, ags.linesRemoved
377}
378
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700379// HasSeenCommits returns true if any commits have been processed
380func (ags *AgentGitState) HasSeenCommits() bool {
381 ags.mu.Lock()
382 defer ags.mu.Unlock()
383 return len(ags.seenCommits) > 0
384}
385
386func (ags *AgentGitState) RetryNumber() int {
387 ags.mu.Lock()
388 defer ags.mu.Unlock()
389 return ags.retryNumber
390}
391
392func (ags *AgentGitState) BranchName(prefix string) string {
393 ags.mu.Lock()
394 defer ags.mu.Unlock()
395 return ags.branchNameLocked(prefix)
396}
397
398func (ags *AgentGitState) branchNameLocked(prefix string) string {
399 if ags.slug == "" {
400 return ""
401 }
402 if ags.retryNumber == 0 {
403 return prefix + ags.slug
404 }
405 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700406}
407
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000408func (ags *AgentGitState) Upstream() string {
409 ags.mu.Lock()
410 defer ags.mu.Unlock()
411 return ags.upstream
412}
413
Earl Lee2e463fb2025-04-17 11:22:22 -0700414type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700415 convo ConvoInterface
416 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700417 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700418 workingDir string
419 repoRoot string // workingDir may be a subdir of repoRoot
420 url string
421 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000422 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700423 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000424 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700425 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700426 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000427 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700428 // State machine to track agent state
429 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000430 // Outside information
431 outsideHostname string
432 outsideOS string
433 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000434 // URL of the git remote 'origin' if it exists
435 gitOrigin string
Philip Zeyliger194bfa82025-06-24 06:03:06 -0700436 // MCP manager for handling MCP server connections
437 mcpManager *mcp.MCPManager
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000438 // Port monitor for tracking TCP ports
439 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700440
441 // Time when the current turn started (reset at the beginning of InnerLoop)
442 startOfTurn time.Time
443
444 // Inbox - for messages from the user to the agent.
445 // sent on by UserMessage
446 // . e.g. when user types into the chat textarea
447 // read from by GatherMessages
448 inbox chan string
449
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000450 // protects cancelTurn
451 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700452 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000453 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700454
455 // protects following
456 mu sync.Mutex
457
458 // Stores all messages for this agent
459 history []AgentMessage
460
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700461 // Iterators add themselves here when they're ready to be notified of new messages.
462 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700463
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000464 // Track outstanding LLM call IDs
465 outstandingLLMCalls map[string]struct{}
466
467 // Track outstanding tool calls by ID with their names
468 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700469}
470
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700471// NewIterator implements CodingAgent.
472func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
473 a.mu.Lock()
474 defer a.mu.Unlock()
475
476 return &MessageIteratorImpl{
477 agent: a,
478 ctx: ctx,
479 nextMessageIdx: nextMessageIdx,
480 ch: make(chan *AgentMessage, 100),
481 }
482}
483
484type MessageIteratorImpl struct {
485 agent *Agent
486 ctx context.Context
487 nextMessageIdx int
488 ch chan *AgentMessage
489 subscribed bool
490}
491
492func (m *MessageIteratorImpl) Close() {
493 m.agent.mu.Lock()
494 defer m.agent.mu.Unlock()
495 // Delete ourselves from the subscribers list
496 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
497 return x == m.ch
498 })
499 close(m.ch)
500}
501
502func (m *MessageIteratorImpl) Next() *AgentMessage {
503 // We avoid subscription at creation to let ourselves catch up to "current state"
504 // before subscribing.
505 if !m.subscribed {
506 m.agent.mu.Lock()
507 if m.nextMessageIdx < len(m.agent.history) {
508 msg := &m.agent.history[m.nextMessageIdx]
509 m.nextMessageIdx++
510 m.agent.mu.Unlock()
511 return msg
512 }
513 // The next message doesn't exist yet, so let's subscribe
514 m.agent.subscribers = append(m.agent.subscribers, m.ch)
515 m.subscribed = true
516 m.agent.mu.Unlock()
517 }
518
519 for {
520 select {
521 case <-m.ctx.Done():
522 m.agent.mu.Lock()
523 // Delete ourselves from the subscribers list
524 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
525 return x == m.ch
526 })
527 m.subscribed = false
528 m.agent.mu.Unlock()
529 return nil
530 case msg, ok := <-m.ch:
531 if !ok {
532 // Close may have been called
533 return nil
534 }
535 if msg.Idx == m.nextMessageIdx {
536 m.nextMessageIdx++
537 return msg
538 }
539 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
540 panic("out of order message")
541 }
542 }
543}
544
Sean McCulloughd9d45812025-04-30 16:53:41 -0700545// Assert that Agent satisfies the CodingAgent interface.
546var _ CodingAgent = &Agent{}
547
548// StateName implements CodingAgent.
549func (a *Agent) CurrentStateName() string {
550 if a.stateMachine == nil {
551 return ""
552 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000553 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700554}
555
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700556// CurrentTodoContent returns the current todo list data as JSON.
557// It returns an empty string if no todos exist.
558func (a *Agent) CurrentTodoContent() string {
559 todoPath := claudetool.TodoFilePath(a.config.SessionID)
560 content, err := os.ReadFile(todoPath)
561 if err != nil {
562 return ""
563 }
564 return string(content)
565}
566
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700567// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
568func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
569 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.
570
571IMPORTANT: 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.
572
573Please create a detailed summary that includes:
574
5751. **User's Request**: What did the user originally ask me to do? What was their goal?
576
5772. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
578
5793. **Key Technical Decisions**: What important technical choices were made during our work and why?
580
5814. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
582
5835. **Next Steps**: What still needs to be done to complete the user's request?
584
5856. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
586
587Focus 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.
588
589Reply with ONLY the summary content - no meta-commentary about creating the summary.`
590
591 userMessage := llm.UserStringMessage(msg)
592 // Use a subconversation with history to get the summary
593 // TODO: We don't have any tools here, so we should have enough tokens
594 // to capture a summary, but we may need to modify the history (e.g., remove
595 // TODO data) to save on some tokens.
596 convo := a.convo.SubConvoWithHistory()
597
598 // Modify the system prompt to provide context about the original task
599 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000600 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 -0700601
602Your 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.
603
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000604Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700605
606 resp, err := convo.SendMessage(userMessage)
607 if err != nil {
608 a.pushToOutbox(ctx, errorMessage(err))
609 return "", err
610 }
611 textContent := collectTextContent(resp)
612
613 // Restore original system prompt (though this subconvo will be discarded)
614 convo.SystemPrompt = originalSystemPrompt
615
616 return textContent, nil
617}
618
619// CompactConversation compacts the current conversation by generating a summary
620// and restarting the conversation with that summary as the initial context
621func (a *Agent) CompactConversation(ctx context.Context) error {
622 summary, err := a.generateConversationSummary(ctx)
623 if err != nil {
624 return fmt.Errorf("failed to generate conversation summary: %w", err)
625 }
626
627 a.mu.Lock()
628
629 // Get usage information before resetting conversation
630 lastUsage := a.convo.LastUsage()
631 contextWindow := a.config.Service.TokenContextWindow()
632 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
633
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000634 // Preserve cumulative usage across compaction
635 cumulativeUsage := a.convo.CumulativeUsage()
636
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700637 // Reset conversation state but keep all other state (git, working dir, etc.)
638 a.firstMessageIndex = len(a.history)
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000639 a.convo = a.initConvoWithUsage(&cumulativeUsage)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700640
641 a.mu.Unlock()
642
643 // Create informative compaction message with token details
644 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
645 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
646 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
647
648 a.pushToOutbox(ctx, AgentMessage{
649 Type: CompactMessageType,
650 Content: compactionMsg,
651 })
652
653 a.pushToOutbox(ctx, AgentMessage{
654 Type: UserMessageType,
655 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),
656 })
657 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)
658
659 return nil
660}
661
Earl Lee2e463fb2025-04-17 11:22:22 -0700662func (a *Agent) URL() string { return a.url }
663
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000664// GetPorts returns the cached list of open TCP ports.
665func (a *Agent) GetPorts() []portlist.Port {
666 if a.portMonitor == nil {
667 return nil
668 }
669 return a.portMonitor.GetPorts()
670}
671
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000672// BranchName returns the git branch name for the conversation.
673func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700674 return a.gitState.BranchName(a.config.BranchPrefix)
675}
676
677// Slug returns the slug identifier for this conversation.
678func (a *Agent) Slug() string {
679 return a.gitState.Slug()
680}
681
682// IncrementRetryNumber increments the retry number for branch naming conflicts
683func (a *Agent) IncrementRetryNumber() {
684 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000685}
686
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000687// OutstandingLLMCallCount returns the number of outstanding LLM calls.
688func (a *Agent) OutstandingLLMCallCount() int {
689 a.mu.Lock()
690 defer a.mu.Unlock()
691 return len(a.outstandingLLMCalls)
692}
693
694// OutstandingToolCalls returns the names of outstanding tool calls.
695func (a *Agent) OutstandingToolCalls() []string {
696 a.mu.Lock()
697 defer a.mu.Unlock()
698
699 tools := make([]string, 0, len(a.outstandingToolCalls))
700 for _, toolName := range a.outstandingToolCalls {
701 tools = append(tools, toolName)
702 }
703 return tools
704}
705
Earl Lee2e463fb2025-04-17 11:22:22 -0700706// OS returns the operating system of the client.
707func (a *Agent) OS() string {
708 return a.config.ClientGOOS
709}
710
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000711func (a *Agent) SessionID() string {
712 return a.config.SessionID
713}
714
philip.zeyliger8773e682025-06-11 21:36:21 -0700715// SSHConnectionString returns the SSH connection string for the container.
716func (a *Agent) SSHConnectionString() string {
717 return a.config.SSHConnectionString
718}
719
Philip Zeyliger18532b22025-04-23 21:11:46 +0000720// OutsideOS returns the operating system of the outside system.
721func (a *Agent) OutsideOS() string {
722 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000723}
724
Philip Zeyliger18532b22025-04-23 21:11:46 +0000725// OutsideHostname returns the hostname of the outside system.
726func (a *Agent) OutsideHostname() string {
727 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000728}
729
Philip Zeyliger18532b22025-04-23 21:11:46 +0000730// OutsideWorkingDir returns the working directory on the outside system.
731func (a *Agent) OutsideWorkingDir() string {
732 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000733}
734
735// GitOrigin returns the URL of the git remote 'origin' if it exists.
736func (a *Agent) GitOrigin() string {
737 return a.gitOrigin
738}
739
bankseancad67b02025-06-27 21:57:05 +0000740// GitUsername returns the git user name from the agent config.
741func (a *Agent) GitUsername() string {
742 return a.config.GitUsername
743}
744
Philip Zeyliger64f60462025-06-16 13:57:10 -0700745// DiffStats returns the number of lines added and removed from sketch-base to HEAD
746func (a *Agent) DiffStats() (int, int) {
747 return a.gitState.DiffStats()
748}
749
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000750func (a *Agent) OpenBrowser(url string) {
751 if !a.IsInContainer() {
752 browser.Open(url)
753 return
754 }
755 // We're in Docker, need to send a request to the Git server
756 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700757 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000758 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700759 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000760 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700761 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000762 return
763 }
764 defer resp.Body.Close()
765 if resp.StatusCode == http.StatusOK {
766 return
767 }
768 body, _ := io.ReadAll(resp.Body)
769 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
770}
771
Sean McCullough96b60dd2025-04-30 09:49:10 -0700772// CurrentState returns the current state of the agent's state machine.
773func (a *Agent) CurrentState() State {
774 return a.stateMachine.CurrentState()
775}
776
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700777func (a *Agent) IsInContainer() bool {
778 return a.config.InDocker
779}
780
781func (a *Agent) FirstMessageIndex() int {
782 a.mu.Lock()
783 defer a.mu.Unlock()
784 return a.firstMessageIndex
785}
786
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700787// SetSlug sets a human-readable identifier for the conversation.
788func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700789 a.mu.Lock()
790 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700791
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700792 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000793 convo, ok := a.convo.(*conversation.Convo)
794 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700795 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000796 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700797}
798
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000799// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700800func (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 +0000801 // Track the tool call
802 a.mu.Lock()
803 a.outstandingToolCalls[id] = toolName
804 a.mu.Unlock()
805}
806
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700807// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
808// If there's only one element in the array and it's a text type, it returns that text directly.
809// It also processes nested ToolResult arrays recursively.
810func contentToString(contents []llm.Content) string {
811 if len(contents) == 0 {
812 return ""
813 }
814
815 // If there's only one element and it's a text type, return it directly
816 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
817 return contents[0].Text
818 }
819
820 // Otherwise, concatenate all text content
821 var result strings.Builder
822 for _, content := range contents {
823 if content.Type == llm.ContentTypeText {
824 result.WriteString(content.Text)
825 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
826 // Recursively process nested tool results
827 result.WriteString(contentToString(content.ToolResult))
828 }
829 }
830
831 return result.String()
832}
833
Earl Lee2e463fb2025-04-17 11:22:22 -0700834// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700835func (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 +0000836 // Remove the tool call from outstanding calls
837 a.mu.Lock()
838 delete(a.outstandingToolCalls, toolID)
839 a.mu.Unlock()
840
Earl Lee2e463fb2025-04-17 11:22:22 -0700841 m := AgentMessage{
842 Type: ToolUseMessageType,
843 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700844 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700845 ToolError: content.ToolError,
846 ToolName: toolName,
847 ToolInput: string(toolInput),
848 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700849 StartTime: content.ToolUseStartTime,
850 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700851 }
852
853 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700854 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
855 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700856 m.Elapsed = &elapsed
857 }
858
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700859 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700860 a.pushToOutbox(ctx, m)
861}
862
863// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700864func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000865 a.mu.Lock()
866 defer a.mu.Unlock()
867 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700868 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
869}
870
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700871// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700872// that need to be displayed (as well as tool calls that we send along when
873// they're done). (It would be reasonable to also mention tool calls when they're
874// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700875func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000876 // Remove the LLM call from outstanding calls
877 a.mu.Lock()
878 delete(a.outstandingLLMCalls, id)
879 a.mu.Unlock()
880
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700881 if resp == nil {
882 // LLM API call failed
883 m := AgentMessage{
884 Type: ErrorMessageType,
885 Content: "API call failed, type 'continue' to try again",
886 }
887 m.SetConvo(convo)
888 a.pushToOutbox(ctx, m)
889 return
890 }
891
Earl Lee2e463fb2025-04-17 11:22:22 -0700892 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700893 if convo.Parent == nil { // subconvos never end the turn
894 switch resp.StopReason {
895 case llm.StopReasonToolUse:
896 // Check whether any of the tool calls are for tools that should end the turn
897 ToolSearch:
898 for _, part := range resp.Content {
899 if part.Type != llm.ContentTypeToolUse {
900 continue
901 }
Sean McCullough021557a2025-05-05 23:20:53 +0000902 // Find the tool by name
903 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700904 if tool.Name == part.ToolName {
905 endOfTurn = tool.EndsTurn
906 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000907 }
908 }
Sean McCullough021557a2025-05-05 23:20:53 +0000909 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700910 default:
911 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000912 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700913 }
914 m := AgentMessage{
915 Type: AgentMessageType,
916 Content: collectTextContent(resp),
917 EndOfTurn: endOfTurn,
918 Usage: &resp.Usage,
919 StartTime: resp.StartTime,
920 EndTime: resp.EndTime,
921 }
922
923 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700924 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700925 var toolCalls []ToolCall
926 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700927 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700928 toolCalls = append(toolCalls, ToolCall{
929 Name: part.ToolName,
930 Input: string(part.ToolInput),
931 ToolCallId: part.ID,
932 })
933 }
934 }
935 m.ToolCalls = toolCalls
936 }
937
938 // Calculate the elapsed time if both start and end times are set
939 if resp.StartTime != nil && resp.EndTime != nil {
940 elapsed := resp.EndTime.Sub(*resp.StartTime)
941 m.Elapsed = &elapsed
942 }
943
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700944 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700945 a.pushToOutbox(ctx, m)
946}
947
948// WorkingDir implements CodingAgent.
949func (a *Agent) WorkingDir() string {
950 return a.workingDir
951}
952
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000953// RepoRoot returns the git repository root directory.
954func (a *Agent) RepoRoot() string {
955 return a.repoRoot
956}
957
Earl Lee2e463fb2025-04-17 11:22:22 -0700958// MessageCount implements CodingAgent.
959func (a *Agent) MessageCount() int {
960 a.mu.Lock()
961 defer a.mu.Unlock()
962 return len(a.history)
963}
964
965// Messages implements CodingAgent.
966func (a *Agent) Messages(start int, end int) []AgentMessage {
967 a.mu.Lock()
968 defer a.mu.Unlock()
969 return slices.Clone(a.history[start:end])
970}
971
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700972// ShouldCompact checks if the conversation should be compacted based on token usage
973func (a *Agent) ShouldCompact() bool {
974 // Get the threshold from environment variable, default to 0.94 (94%)
975 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
976 // and a little bit of buffer.)
977 thresholdRatio := 0.94
978 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
979 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
980 thresholdRatio = parsed
981 }
982 }
983
984 // Get the most recent usage to check current context size
985 lastUsage := a.convo.LastUsage()
986
987 if lastUsage.InputTokens == 0 {
988 // No API calls made yet
989 return false
990 }
991
992 // Calculate the current context size from the last API call
993 // This includes all tokens that were part of the input context:
994 // - Input tokens (user messages, system prompt, conversation history)
995 // - Cache read tokens (cached parts of the context)
996 // - Cache creation tokens (new parts being cached)
997 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
998
999 // Get the service's token context window
1000 service := a.config.Service
1001 contextWindow := service.TokenContextWindow()
1002
1003 // Calculate threshold
1004 threshold := uint64(float64(contextWindow) * thresholdRatio)
1005
1006 // Check if we've exceeded the threshold
1007 return currentContextSize >= threshold
1008}
1009
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001010func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -07001011 return a.originalBudget
1012}
1013
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001014// Upstream returns the upstream branch for git work
1015func (a *Agent) Upstream() string {
1016 return a.gitState.Upstream()
1017}
1018
Earl Lee2e463fb2025-04-17 11:22:22 -07001019// AgentConfig contains configuration for creating a new Agent.
1020type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001021 Context context.Context
1022 Service llm.Service
1023 Budget conversation.Budget
1024 GitUsername string
1025 GitEmail string
1026 SessionID string
1027 ClientGOOS string
1028 ClientGOARCH string
1029 InDocker bool
1030 OneShot bool
1031 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +00001032 // Outside information
1033 OutsideHostname string
1034 OutsideOS string
1035 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001036
1037 // Outtie's HTTP to, e.g., open a browser
1038 OutsideHTTP string
1039 // Outtie's Git server
1040 GitRemoteAddr string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001041 // Upstream branch for git work
1042 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001043 // Commit to checkout from Outtie
1044 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001045 // Prefix for git branches created by sketch
1046 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001047 // LinkToGitHub enables GitHub branch linking in UI
1048 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001049 // SSH connection string for connecting to the container
1050 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001051 // Skaband client for session history (optional)
1052 SkabandClient *skabandclient.SkabandClient
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001053 // MCP server configurations
1054 MCPServers []string
Earl Lee2e463fb2025-04-17 11:22:22 -07001055}
1056
1057// NewAgent creates a new Agent.
1058// It is not usable until Init() is called.
1059func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001060 // Set default branch prefix if not specified
1061 if config.BranchPrefix == "" {
1062 config.BranchPrefix = "sketch/"
1063 }
1064
Earl Lee2e463fb2025-04-17 11:22:22 -07001065 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001066 config: config,
1067 ready: make(chan struct{}),
1068 inbox: make(chan string, 100),
1069 subscribers: make([]chan *AgentMessage, 0),
1070 startedAt: time.Now(),
1071 originalBudget: config.Budget,
1072 gitState: AgentGitState{
1073 seenCommits: make(map[string]bool),
1074 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001075 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001076 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001077 outsideHostname: config.OutsideHostname,
1078 outsideOS: config.OutsideOS,
1079 outsideWorkingDir: config.OutsideWorkingDir,
1080 outstandingLLMCalls: make(map[string]struct{}),
1081 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001082 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001083 workingDir: config.WorkingDir,
1084 outsideHTTP: config.OutsideHTTP,
Philip Zeyligerda623b52025-07-04 01:12:38 +00001085
1086 mcpManager: mcp.NewMCPManager(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001087 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001088
1089 // Initialize port monitor with 5-second interval
1090 agent.portMonitor = NewPortMonitor(agent, 5*time.Second)
1091
Earl Lee2e463fb2025-04-17 11:22:22 -07001092 return agent
1093}
1094
1095type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001096 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001097
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001098 InDocker bool
1099 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001100}
1101
1102func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001103 if a.convo != nil {
1104 return fmt.Errorf("Agent.Init: already initialized")
1105 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001106 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001107 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001108
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001109 if !ini.NoGit {
1110 // Capture the original origin before we potentially replace it below
1111 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
Philip Zeyligere1c8b7b2025-07-03 14:50:26 -07001112
1113 // Configure git user settings
1114 if a.config.GitEmail != "" {
1115 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.email", a.config.GitEmail)
1116 cmd.Dir = a.workingDir
1117 if out, err := cmd.CombinedOutput(); err != nil {
1118 return fmt.Errorf("git config --global user.email: %s: %v", out, err)
1119 }
1120 }
1121 if a.config.GitUsername != "" {
1122 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.name", a.config.GitUsername)
1123 cmd.Dir = a.workingDir
1124 if out, err := cmd.CombinedOutput(); err != nil {
1125 return fmt.Errorf("git config --global user.name: %s: %v", out, err)
1126 }
1127 }
1128 // Configure git http.postBuffer
1129 cmd := exec.CommandContext(ctx, "git", "config", "--global", "http.postBuffer", "524288000")
1130 cmd.Dir = a.workingDir
1131 if out, err := cmd.CombinedOutput(); err != nil {
1132 return fmt.Errorf("git config --global http.postBuffer: %s: %v", out, err)
1133 }
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001134 }
1135
Philip Zeyliger222bf412025-06-04 16:42:58 +00001136 // If a remote git addr was specified, we configure the origin remote
Philip Zeyligerf2872992025-05-22 10:35:28 -07001137 if a.gitState.gitRemoteAddr != "" {
1138 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
Philip Zeyliger222bf412025-06-04 16:42:58 +00001139
1140 // Remove existing origin remote if it exists
1141 cmd := exec.CommandContext(ctx, "git", "remote", "remove", "origin")
Philip Zeyligerf2872992025-05-22 10:35:28 -07001142 cmd.Dir = a.workingDir
1143 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001144 // Ignore error if origin doesn't exist
1145 slog.DebugContext(ctx, "git remote remove origin (ignoring if not exists)", slog.String("output", string(out)))
Philip Zeyligerf2872992025-05-22 10:35:28 -07001146 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001147
1148 // Add the new remote as origin
1149 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", a.gitState.gitRemoteAddr)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001150 cmd.Dir = a.workingDir
1151 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001152 return fmt.Errorf("git remote add origin: %s: %v", out, err)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001153 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001154
Philip Zeyligerf2872992025-05-22 10:35:28 -07001155 }
1156
1157 // If a commit was specified, we fetch and reset to it.
1158 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001159 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1160
Earl Lee2e463fb2025-04-17 11:22:22 -07001161 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001162 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001163 if out, err := cmd.CombinedOutput(); err != nil {
1164 return fmt.Errorf("git stash: %s: %v", out, err)
1165 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001166 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001167 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001168 if out, err := cmd.CombinedOutput(); err != nil {
1169 return fmt.Errorf("git fetch: %s: %w", out, err)
1170 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001171 // The -B resets the branch if it already exists (or creates it if it doesn't)
1172 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001173 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001174 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1175 // Remove git hooks if they exist and retry
1176 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001177 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001178 if _, statErr := os.Stat(hookPath); statErr == nil {
1179 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1180 slog.String("error", err.Error()),
1181 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001182 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001183 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1184 }
1185
1186 // Retry the checkout operation
Philip Zeyliger1417b692025-06-12 11:07:04 -07001187 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001188 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001189 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001190 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 +01001191 }
1192 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001193 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001194 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001195 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001196 } else if a.IsInContainer() {
1197 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1198 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1199 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1200 cmd.Dir = a.workingDir
1201 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1202 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1203 }
1204 } else {
1205 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001206 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001207
1208 if ini.HostAddr != "" {
1209 a.url = "http://" + ini.HostAddr
1210 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001211
1212 if !ini.NoGit {
1213 repoRoot, err := repoRoot(ctx, a.workingDir)
1214 if err != nil {
1215 return fmt.Errorf("repoRoot: %w", err)
1216 }
1217 a.repoRoot = repoRoot
1218
Earl Lee2e463fb2025-04-17 11:22:22 -07001219 if err != nil {
1220 return fmt.Errorf("resolveRef: %w", err)
1221 }
Philip Zeyliger49edc922025-05-14 09:45:45 -07001222
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001223 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001224 if err := setupGitHooks(a.repoRoot); err != nil {
1225 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1226 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001227 }
1228
Philip Zeyliger49edc922025-05-14 09:45:45 -07001229 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1230 cmd.Dir = repoRoot
1231 if out, err := cmd.CombinedOutput(); err != nil {
1232 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1233 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001234
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001235 slog.Info("running codebase analysis")
1236 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1237 if err != nil {
1238 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001239 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001240 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001241
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001242 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001243 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001244 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001245 }
1246 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001247
Earl Lee2e463fb2025-04-17 11:22:22 -07001248 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001249 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001250 a.convo = a.initConvo()
1251 close(a.ready)
1252 return nil
1253}
1254
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001255//go:embed agent_system_prompt.txt
1256var agentSystemPrompt string
1257
Earl Lee2e463fb2025-04-17 11:22:22 -07001258// initConvo initializes the conversation.
1259// It must not be called until all agent fields are initialized,
1260// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001261func (a *Agent) initConvo() *conversation.Convo {
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001262 return a.initConvoWithUsage(nil)
1263}
1264
1265// initConvoWithUsage initializes the conversation with optional preserved usage.
1266func (a *Agent) initConvoWithUsage(usage *conversation.CumulativeUsage) *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001267 ctx := a.config.Context
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001268 convo := conversation.New(ctx, a.config.Service, usage)
Earl Lee2e463fb2025-04-17 11:22:22 -07001269 convo.PromptCaching = true
1270 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001271 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001272 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001273
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001274 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1275 bashPermissionCheck := func(command string) error {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001276 if a.gitState.Slug() != "" {
1277 return nil // branch is set up
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001278 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001279 willCommit, err := bashkit.WillRunGitCommit(command)
1280 if err != nil {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001281 return nil // fail open
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001282 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001283 if willCommit {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001284 return fmt.Errorf("you must use the set-slug tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001285 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001286 return nil
1287 }
1288
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001289 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001290
Earl Lee2e463fb2025-04-17 11:22:22 -07001291 // Register all tools with the conversation
1292 // When adding, removing, or modifying tools here, double-check that the termui tool display
1293 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001294
1295 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001296 _, supportsScreenshots := a.config.Service.(*ant.Service)
1297 var bTools []*llm.Tool
1298 var browserCleanup func()
1299
1300 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1301 // Add cleanup function to context cancel
1302 go func() {
1303 <-a.config.Context.Done()
1304 browserCleanup()
1305 }()
1306 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001307
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001308 convo.Tools = []*llm.Tool{
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001309 bashTool, claudetool.Keyword, claudetool.Patch(a.patchCallback),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001310 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.setSlugTool(), a.commitMessageStyleTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001311 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001312 }
1313
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001314 // One-shot mode is non-interactive, multiple choice requires human response
1315 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001316 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001317 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001318
1319 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001320
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001321 // Add MCP tools if configured
1322 if len(a.config.MCPServers) > 0 {
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001323
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001324 slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers))
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001325 serverConfigs, parseErrors := mcp.ParseServerConfigs(ctx, a.config.MCPServers)
1326
1327 // Replace any headers with value _sketch_public_key_ and _sketch_session_id_ with those values.
1328 for i := range serverConfigs {
1329 if serverConfigs[i].Headers != nil {
1330 for key, value := range serverConfigs[i].Headers {
Philip Zeyligerf2814ea2025-06-30 10:16:50 -07001331 // Replace env placeholders. E.g., "env:FOO" becomes os.Getenv("FOO")
1332 if strings.HasPrefix(value, "env:") {
1333 serverConfigs[i].Headers[key] = os.Getenv(value[4:])
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001334 }
1335 }
1336 }
1337 }
1338 mcpConnections, mcpErrors := a.mcpManager.ConnectToServerConfigs(ctx, serverConfigs, 10*time.Second, parseErrors)
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001339
1340 if len(mcpErrors) > 0 {
1341 for _, err := range mcpErrors {
1342 slog.ErrorContext(ctx, "MCP connection error", "error", err)
1343 // Send agent message about MCP connection failures
1344 a.pushToOutbox(ctx, AgentMessage{
1345 Type: ErrorMessageType,
1346 Content: fmt.Sprintf("MCP server connection failed: %v", err),
1347 })
1348 }
1349 }
1350
1351 if len(mcpConnections) > 0 {
1352 // Add tools from all successful connections
1353 totalTools := 0
1354 for _, connection := range mcpConnections {
1355 convo.Tools = append(convo.Tools, connection.Tools...)
1356 totalTools += len(connection.Tools)
1357 // Log tools per server using structured data
1358 slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames)
1359 }
1360 slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools)
1361 } else {
1362 slog.InfoContext(ctx, "No MCP tools available after connection attempts")
1363 }
1364 }
1365
Earl Lee2e463fb2025-04-17 11:22:22 -07001366 convo.Listener = a
1367 return convo
1368}
1369
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001370var multipleChoiceTool = &llm.Tool{
1371 Name: "multiplechoice",
1372 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.",
1373 EndsTurn: true,
1374 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001375 "type": "object",
1376 "description": "The question and a list of answers you would expect the user to choose from.",
1377 "properties": {
1378 "question": {
1379 "type": "string",
1380 "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?'"
1381 },
1382 "responseOptions": {
1383 "type": "array",
1384 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1385 "items": {
1386 "type": "object",
1387 "properties": {
1388 "caption": {
1389 "type": "string",
1390 "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'"
1391 },
1392 "responseText": {
1393 "type": "string",
1394 "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'"
1395 }
1396 },
1397 "required": ["caption", "responseText"]
1398 }
1399 }
1400 },
1401 "required": ["question", "responseOptions"]
1402}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001403 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1404 // The Run logic for "multiplechoice" tool is a no-op on the server.
1405 // The UI will present a list of options for the user to select from,
1406 // and that's it as far as "executing" the tool_use goes.
1407 // When the user *does* select one of the presented options, that
1408 // responseText gets sent as a chat message on behalf of the user.
1409 return llm.TextContent("end your turn and wait for the user to respond"), nil
1410 },
Sean McCullough485afc62025-04-28 14:28:39 -07001411}
1412
1413type MultipleChoiceOption struct {
1414 Caption string `json:"caption"`
1415 ResponseText string `json:"responseText"`
1416}
1417
1418type MultipleChoiceParams struct {
1419 Question string `json:"question"`
1420 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1421}
1422
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001423// branchExists reports whether branchName exists, either locally or in well-known remotes.
1424func branchExists(dir, branchName string) bool {
1425 refs := []string{
1426 "refs/heads/",
1427 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001428 }
1429 for _, ref := range refs {
1430 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1431 cmd.Dir = dir
1432 if cmd.Run() == nil { // exit code 0 means branch exists
1433 return true
1434 }
1435 }
1436 return false
1437}
1438
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001439func (a *Agent) setSlugTool() *llm.Tool {
1440 return &llm.Tool{
1441 Name: "set-slug",
1442 Description: `Set a short slug as an identifier for this conversation.`,
Earl Lee2e463fb2025-04-17 11:22:22 -07001443 InputSchema: json.RawMessage(`{
1444 "type": "object",
1445 "properties": {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001446 "slug": {
Earl Lee2e463fb2025-04-17 11:22:22 -07001447 "type": "string",
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001448 "description": "A 2-3 word alphanumeric hyphenated slug, imperative tense"
Earl Lee2e463fb2025-04-17 11:22:22 -07001449 }
1450 },
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001451 "required": ["slug"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001452}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001453 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001454 var params struct {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001455 Slug string `json:"slug"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001456 }
1457 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001458 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001459 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001460 // Prevent slug changes if there have been git changes
1461 // This lets the agent change its mind about a good slug,
1462 // while ensuring that once a branch has been pushed, it remains stable.
1463 if s := a.Slug(); s != "" && s != params.Slug && a.gitState.HasSeenCommits() {
1464 return nil, fmt.Errorf("slug already set to %q", s)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001465 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001466 if params.Slug == "" {
1467 return nil, fmt.Errorf("slug parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001468 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001469 slug := cleanSlugName(params.Slug)
1470 if slug == "" {
1471 return nil, fmt.Errorf("slug parameter could not be converted to a valid slug")
1472 }
1473 a.SetSlug(slug)
1474 // TODO: do this by a call to outie, rather than semi-guessing from innie
1475 if branchExists(a.workingDir, a.BranchName()) {
1476 return nil, fmt.Errorf("slug %q already exists; please choose a different slug", slug)
1477 }
1478 return llm.TextContent("OK"), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001479 },
1480 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001481}
1482
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001483func (a *Agent) commitMessageStyleTool() *llm.Tool {
1484 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 +00001485 preCommit := &llm.Tool{
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001486 Name: "commit-message-style",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001487 Description: description,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001488 InputSchema: llm.EmptySchema(),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001489 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001490 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1491 if err != nil {
1492 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1493 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001494 return llm.TextContent(styleHint), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001495 },
1496 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001497 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001498}
1499
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001500// patchCallback is the agent's patch tool callback.
1501// It warms the codereview cache in the background.
1502func (a *Agent) patchCallback(input claudetool.PatchInput, result []llm.Content, err error) ([]llm.Content, error) {
1503 if a.codereview != nil {
1504 a.codereview.WarmTestCache(input.Path)
1505 }
1506 return result, err
1507}
1508
Earl Lee2e463fb2025-04-17 11:22:22 -07001509func (a *Agent) Ready() <-chan struct{} {
1510 return a.ready
1511}
1512
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001513// BranchPrefix returns the configured branch prefix
1514func (a *Agent) BranchPrefix() string {
1515 return a.config.BranchPrefix
1516}
1517
philip.zeyliger6d3de482025-06-10 19:38:14 -07001518// LinkToGitHub returns whether GitHub branch linking is enabled
1519func (a *Agent) LinkToGitHub() bool {
1520 return a.config.LinkToGitHub
1521}
1522
Earl Lee2e463fb2025-04-17 11:22:22 -07001523func (a *Agent) UserMessage(ctx context.Context, msg string) {
1524 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1525 a.inbox <- msg
1526}
1527
Earl Lee2e463fb2025-04-17 11:22:22 -07001528func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1529 return a.convo.CancelToolUse(toolUseID, cause)
1530}
1531
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001532func (a *Agent) CancelTurn(cause error) {
1533 a.cancelTurnMu.Lock()
1534 defer a.cancelTurnMu.Unlock()
1535 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001536 // Force state transition to cancelled state
1537 ctx := a.config.Context
1538 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001539 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001540 }
1541}
1542
1543func (a *Agent) Loop(ctxOuter context.Context) {
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001544 // Start port monitoring
1545 if a.portMonitor != nil && a.IsInContainer() {
1546 if err := a.portMonitor.Start(ctxOuter); err != nil {
1547 slog.WarnContext(ctxOuter, "Failed to start port monitor", "error", err)
1548 } else {
1549 slog.InfoContext(ctxOuter, "Port monitor started")
1550 }
1551 }
1552
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001553 // Set up cleanup when context is done
1554 defer func() {
1555 if a.mcpManager != nil {
1556 a.mcpManager.Close()
1557 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001558 if a.portMonitor != nil && a.IsInContainer() {
1559 a.portMonitor.Stop()
1560 }
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001561 }()
1562
Earl Lee2e463fb2025-04-17 11:22:22 -07001563 for {
1564 select {
1565 case <-ctxOuter.Done():
1566 return
1567 default:
1568 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001569 a.cancelTurnMu.Lock()
1570 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001571 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001572 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001573 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001574 a.cancelTurn = cancel
1575 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001576 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1577 if err != nil {
1578 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1579 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001580 cancel(nil)
1581 }
1582 }
1583}
1584
1585func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1586 if m.Timestamp.IsZero() {
1587 m.Timestamp = time.Now()
1588 }
1589
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001590 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1591 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1592 m.Content = m.ToolResult
1593 }
1594
Earl Lee2e463fb2025-04-17 11:22:22 -07001595 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1596 if m.EndOfTurn && m.Type == AgentMessageType {
1597 turnDuration := time.Since(a.startOfTurn)
1598 m.TurnDuration = &turnDuration
1599 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1600 }
1601
Earl Lee2e463fb2025-04-17 11:22:22 -07001602 a.mu.Lock()
1603 defer a.mu.Unlock()
1604 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001605 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001606 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001607
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001608 // Notify all subscribers
1609 for _, ch := range a.subscribers {
1610 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001611 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001612}
1613
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001614func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1615 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001616 if block {
1617 select {
1618 case <-ctx.Done():
1619 return m, ctx.Err()
1620 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001621 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001622 }
1623 }
1624 for {
1625 select {
1626 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001627 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001628 default:
1629 return m, nil
1630 }
1631 }
1632}
1633
Sean McCullough885a16a2025-04-30 02:49:25 +00001634// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001635func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001636 // Reset the start of turn time
1637 a.startOfTurn = time.Now()
1638
Sean McCullough96b60dd2025-04-30 09:49:10 -07001639 // Transition to waiting for user input state
1640 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1641
Sean McCullough885a16a2025-04-30 02:49:25 +00001642 // Process initial user message
1643 initialResp, err := a.processUserMessage(ctx)
1644 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001645 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001646 return err
1647 }
1648
1649 // Handle edge case where both initialResp and err are nil
1650 if initialResp == nil {
1651 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001652 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1653
Sean McCullough9f4b8082025-04-30 17:34:07 +00001654 a.pushToOutbox(ctx, errorMessage(err))
1655 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001656 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001657
Earl Lee2e463fb2025-04-17 11:22:22 -07001658 // We do this as we go, but let's also do it at the end of the turn
1659 defer func() {
1660 if _, err := a.handleGitCommits(ctx); err != nil {
1661 // Just log the error, don't stop execution
1662 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1663 }
1664 }()
1665
Sean McCullougha1e0e492025-05-01 10:51:08 -07001666 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001667 resp := initialResp
1668 for {
1669 // Check if we are over budget
1670 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001671 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001672 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001673 }
1674
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001675 // Check if we should compact the conversation
1676 if a.ShouldCompact() {
1677 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1678 if err := a.CompactConversation(ctx); err != nil {
1679 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1680 return err
1681 }
1682 // After compaction, end this turn and start fresh
1683 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1684 return nil
1685 }
1686
Sean McCullough885a16a2025-04-30 02:49:25 +00001687 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001688 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001689 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001690 break
1691 }
1692
Sean McCullough96b60dd2025-04-30 09:49:10 -07001693 // Transition to tool use requested state
1694 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1695
Sean McCullough885a16a2025-04-30 02:49:25 +00001696 // Handle tool execution
1697 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1698 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001699 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001700 }
1701
Sean McCullougha1e0e492025-05-01 10:51:08 -07001702 if toolResp == nil {
1703 return fmt.Errorf("cannot continue conversation with a nil tool response")
1704 }
1705
Sean McCullough885a16a2025-04-30 02:49:25 +00001706 // Set the response for the next iteration
1707 resp = toolResp
1708 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001709
1710 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001711}
1712
1713// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001714func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001715 // Wait for at least one message from the user
1716 msgs, err := a.GatherMessages(ctx, true)
1717 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001718 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001719 return nil, err
1720 }
1721
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001722 userMessage := llm.Message{
1723 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001724 Content: msgs,
1725 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001726
Sean McCullough96b60dd2025-04-30 09:49:10 -07001727 // Transition to sending to LLM state
1728 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1729
Sean McCullough885a16a2025-04-30 02:49:25 +00001730 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001731 resp, err := a.convo.SendMessage(userMessage)
1732 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001733 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001734 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001735 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001736 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001737
Sean McCullough96b60dd2025-04-30 09:49:10 -07001738 // Transition to processing LLM response state
1739 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1740
Sean McCullough885a16a2025-04-30 02:49:25 +00001741 return resp, nil
1742}
1743
1744// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001745func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1746 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001747 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001748 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001749
Sean McCullough96b60dd2025-04-30 09:49:10 -07001750 // Transition to checking for cancellation state
1751 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1752
Sean McCullough885a16a2025-04-30 02:49:25 +00001753 // Check if the operation was cancelled by the user
1754 select {
1755 case <-ctx.Done():
1756 // Don't actually run any of the tools, but rather build a response
1757 // for each tool_use message letting the LLM know that user canceled it.
1758 var err error
1759 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001760 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001761 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001762 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001763 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001764 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001765 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001766 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001767 // Transition to running tool state
1768 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1769
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001770 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001771 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001772 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001773
1774 // Execute the tools
1775 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001776 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001777 if ctx.Err() != nil { // e.g. the user canceled the operation
1778 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001779 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001780 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001781 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001782 a.pushToOutbox(ctx, errorMessage(err))
1783 }
1784 }
1785
1786 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001787 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001788 autoqualityMessages := a.processGitChanges(ctx)
1789
1790 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001791 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001792 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001793 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001794 return false, nil
1795 }
1796
1797 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001798 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1799 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001800}
1801
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001802// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001803func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001804 // Check for git commits
1805 _, err := a.handleGitCommits(ctx)
1806 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001807 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001808 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001809 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001810 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001811}
1812
1813// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1814// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001815func (a *Agent) processGitChanges(ctx context.Context) []string {
1816 // Check for git commits after tool execution
1817 newCommits, err := a.handleGitCommits(ctx)
1818 if err != nil {
1819 // Just log the error, don't stop execution
1820 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1821 return nil
1822 }
1823
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001824 // Run mechanical checks if there was exactly one new commit.
1825 if len(newCommits) != 1 {
1826 return nil
1827 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001828 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001829 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1830 msg := a.codereview.RunMechanicalChecks(ctx)
1831 if msg != "" {
1832 a.pushToOutbox(ctx, AgentMessage{
1833 Type: AutoMessageType,
1834 Content: msg,
1835 Timestamp: time.Now(),
1836 })
1837 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001838 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001839
1840 return autoqualityMessages
1841}
1842
1843// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001844func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001845 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001846 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001847 msgs, err := a.GatherMessages(ctx, false)
1848 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001849 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001850 return false, nil
1851 }
1852
1853 // Inject any auto-generated messages from quality checks
1854 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001855 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001856 }
1857
1858 // Handle cancellation by appending a message about it
1859 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001860 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001861 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001862 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001863 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1864 } else if err := a.convo.OverBudget(); err != nil {
1865 // Handle budget issues by appending a message about it
1866 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 -07001867 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001868 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1869 }
1870
1871 // Combine tool results with user messages
1872 results = append(results, msgs...)
1873
1874 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001875 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001876 resp, err := a.convo.SendMessage(llm.Message{
1877 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001878 Content: results,
1879 })
1880 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001881 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001882 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1883 return true, nil // Return true to continue the conversation, but with no response
1884 }
1885
Sean McCullough96b60dd2025-04-30 09:49:10 -07001886 // Transition back to processing LLM response
1887 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1888
Sean McCullough885a16a2025-04-30 02:49:25 +00001889 if cancelled {
1890 return false, nil
1891 }
1892
1893 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001894}
1895
1896func (a *Agent) overBudget(ctx context.Context) error {
1897 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001898 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001899 m := budgetMessage(err)
1900 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001901 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001902 a.convo.ResetBudget(a.originalBudget)
1903 return err
1904 }
1905 return nil
1906}
1907
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001908func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001909 // Collect all text content
1910 var allText strings.Builder
1911 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001912 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001913 if allText.Len() > 0 {
1914 allText.WriteString("\n\n")
1915 }
1916 allText.WriteString(content.Text)
1917 }
1918 }
1919 return allText.String()
1920}
1921
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001922func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001923 a.mu.Lock()
1924 defer a.mu.Unlock()
1925 return a.convo.CumulativeUsage()
1926}
1927
Earl Lee2e463fb2025-04-17 11:22:22 -07001928// Diff returns a unified diff of changes made since the agent was instantiated.
1929func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001930 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001931 return "", fmt.Errorf("no initial commit reference available")
1932 }
1933
1934 // Find the repository root
1935 ctx := context.Background()
1936
1937 // If a specific commit hash is provided, show just that commit's changes
1938 if commit != nil && *commit != "" {
1939 // Validate that the commit looks like a valid git SHA
1940 if !isValidGitSHA(*commit) {
1941 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1942 }
1943
1944 // Get the diff for just this commit
1945 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1946 cmd.Dir = a.repoRoot
1947 output, err := cmd.CombinedOutput()
1948 if err != nil {
1949 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1950 }
1951 return string(output), nil
1952 }
1953
1954 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001955 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001956 cmd.Dir = a.repoRoot
1957 output, err := cmd.CombinedOutput()
1958 if err != nil {
1959 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1960 }
1961
1962 return string(output), nil
1963}
1964
Philip Zeyliger49edc922025-05-14 09:45:45 -07001965// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1966// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1967func (a *Agent) SketchGitBaseRef() string {
1968 if a.IsInContainer() {
1969 return "sketch-base"
1970 } else {
1971 return "sketch-base-" + a.SessionID()
1972 }
1973}
1974
1975// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1976func (a *Agent) SketchGitBase() string {
1977 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1978 cmd.Dir = a.repoRoot
1979 output, err := cmd.CombinedOutput()
1980 if err != nil {
1981 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1982 return "HEAD"
1983 }
1984 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001985}
1986
Pokey Rule7a113622025-05-12 10:58:45 +01001987// removeGitHooks removes the Git hooks directory from the repository
1988func removeGitHooks(_ context.Context, repoPath string) error {
1989 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1990
1991 // Check if hooks directory exists
1992 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1993 // Directory doesn't exist, nothing to do
1994 return nil
1995 }
1996
1997 // Remove the hooks directory
1998 err := os.RemoveAll(hooksDir)
1999 if err != nil {
2000 return fmt.Errorf("failed to remove git hooks directory: %w", err)
2001 }
2002
2003 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00002004 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01002005 if err != nil {
2006 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
2007 }
2008
2009 return nil
2010}
2011
Philip Zeyligerf2872992025-05-22 10:35:28 -07002012func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00002013 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002014 for _, msg := range msgs {
2015 a.pushToOutbox(ctx, msg)
2016 }
2017 return commits, error
2018}
2019
Earl Lee2e463fb2025-04-17 11:22:22 -07002020// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002021// under docker, new HEADs are pushed to a branch according to the slug.
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00002022func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002023 ags.mu.Lock()
2024 defer ags.mu.Unlock()
2025
2026 msgs := []AgentMessage{}
2027 if repoRoot == "" {
2028 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002029 }
2030
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002031 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07002032 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002033 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07002034 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002035 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002036 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07002037 }
2038 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002039 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07002040 }()
2041
Philip Zeyliger64f60462025-06-16 13:57:10 -07002042 // Compute diff stats from baseRef to HEAD when HEAD changes
2043 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
2044 // Log error but don't fail the entire operation
2045 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
2046 } else {
2047 // Set diff stats directly since we already hold the mutex
2048 ags.linesAdded = added
2049 ags.linesRemoved = removed
2050 }
2051
Earl Lee2e463fb2025-04-17 11:22:22 -07002052 // Get new commits. Because it's possible that the agent does rebases, fixups, and
2053 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
2054 // to the last 100 commits.
2055 var commits []*GitCommit
2056
2057 // Get commits since the initial commit
2058 // Format: <hash>\0<subject>\0<body>\0
2059 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
2060 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002061 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 -07002062 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07002063 output, err := cmd.Output()
2064 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002065 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07002066 }
2067
2068 // Parse git log output and filter out already seen commits
2069 parsedCommits := parseGitLog(string(output))
2070
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002071 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07002072
2073 // Filter out commits we've already seen
2074 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002075 if commit.Hash == sketch {
2076 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07002077 }
2078
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002079 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
2080 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07002081 continue
2082 }
2083
2084 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07002085 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07002086
2087 // Add to our list of new commits
2088 commits = append(commits, &commit)
2089 }
2090
Philip Zeyligerf2872992025-05-22 10:35:28 -07002091 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002092 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07002093 // 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 -07002094 sketchCommit = &GitCommit{}
2095 sketchCommit.Hash = sketch
2096 sketchCommit.Subject = "unknown"
2097 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07002098 }
2099
Earl Lee2e463fb2025-04-17 11:22:22 -07002100 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
2101 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
2102 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00002103
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002104 // 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 +00002105 var out []byte
2106 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002107 originalRetryNumber := ags.retryNumber
2108 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00002109 for retries := range 10 {
2110 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07002111 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002112 }
2113
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002114 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002115 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002116 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002117 out, err = cmd.CombinedOutput()
2118
2119 if err == nil {
2120 // Success! Break out of the retry loop
2121 break
2122 }
2123
2124 // Check if this is the "refusing to update checked out branch" error
2125 if !strings.Contains(string(out), "refusing to update checked out branch") {
2126 // This is a different error, so don't retry
2127 break
2128 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002129 }
2130
2131 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002132 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002133 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002134 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002135 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002136 if ags.retryNumber != originalRetryNumber {
2137 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002138 msgs = append(msgs, AgentMessage{
2139 Type: AutoMessageType,
2140 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002141 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 +00002142 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002143 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002144 }
2145 }
2146
2147 // If we found new commits, create a message
2148 if len(commits) > 0 {
2149 msg := AgentMessage{
2150 Type: CommitMessageType,
2151 Timestamp: time.Now(),
2152 Commits: commits,
2153 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002154 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002155 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002156 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002157}
2158
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002159func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002160 return strings.Map(func(r rune) rune {
2161 // lowercase
2162 if r >= 'A' && r <= 'Z' {
2163 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002164 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002165 // replace spaces with dashes
2166 if r == ' ' {
2167 return '-'
2168 }
2169 // allow alphanumerics and dashes
2170 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2171 return r
2172 }
2173 return -1
2174 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002175}
2176
2177// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2178// and returns an array of GitCommit structs.
2179func parseGitLog(output string) []GitCommit {
2180 var commits []GitCommit
2181
2182 // No output means no commits
2183 if len(output) == 0 {
2184 return commits
2185 }
2186
2187 // Split by NULL byte
2188 parts := strings.Split(output, "\x00")
2189
2190 // Process in triplets (hash, subject, body)
2191 for i := 0; i < len(parts); i++ {
2192 // Skip empty parts
2193 if parts[i] == "" {
2194 continue
2195 }
2196
2197 // This should be a hash
2198 hash := strings.TrimSpace(parts[i])
2199
2200 // Make sure we have at least a subject part available
2201 if i+1 >= len(parts) {
2202 break // No more parts available
2203 }
2204
2205 // Get the subject
2206 subject := strings.TrimSpace(parts[i+1])
2207
2208 // Get the body if available
2209 body := ""
2210 if i+2 < len(parts) {
2211 body = strings.TrimSpace(parts[i+2])
2212 }
2213
2214 // Skip to the next triplet
2215 i += 2
2216
2217 commits = append(commits, GitCommit{
2218 Hash: hash,
2219 Subject: subject,
2220 Body: body,
2221 })
2222 }
2223
2224 return commits
2225}
2226
2227func repoRoot(ctx context.Context, dir string) (string, error) {
2228 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2229 stderr := new(strings.Builder)
2230 cmd.Stderr = stderr
2231 cmd.Dir = dir
2232 out, err := cmd.Output()
2233 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002234 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002235 }
2236 return strings.TrimSpace(string(out)), nil
2237}
2238
2239func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2240 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2241 stderr := new(strings.Builder)
2242 cmd.Stderr = stderr
2243 cmd.Dir = dir
2244 out, err := cmd.Output()
2245 if err != nil {
2246 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2247 }
2248 // TODO: validate that out is valid hex
2249 return strings.TrimSpace(string(out)), nil
2250}
2251
2252// isValidGitSHA validates if a string looks like a valid git SHA hash.
2253// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2254func isValidGitSHA(sha string) bool {
2255 // Git SHA must be a hexadecimal string with at least 4 characters
2256 if len(sha) < 4 || len(sha) > 40 {
2257 return false
2258 }
2259
2260 // Check if the string only contains hexadecimal characters
2261 for _, char := range sha {
2262 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2263 return false
2264 }
2265 }
2266
2267 return true
2268}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002269
Philip Zeyliger64f60462025-06-16 13:57:10 -07002270// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2271func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2272 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2273 cmd.Dir = repoRoot
2274 out, err := cmd.Output()
2275 if err != nil {
2276 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2277 }
2278
2279 var totalAdded, totalRemoved int
2280 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2281 for _, line := range lines {
2282 if line == "" {
2283 continue
2284 }
2285 parts := strings.Fields(line)
2286 if len(parts) < 2 {
2287 continue
2288 }
2289 // Format: <added>\t<removed>\t<filename>
2290 if added, err := strconv.Atoi(parts[0]); err == nil {
2291 totalAdded += added
2292 }
2293 if removed, err := strconv.Atoi(parts[1]); err == nil {
2294 totalRemoved += removed
2295 }
2296 }
2297
2298 return totalAdded, totalRemoved, nil
2299}
2300
Philip Zeyligerd1402952025-04-23 03:54:37 +00002301// getGitOrigin returns the URL of the git remote 'origin' if it exists
2302func getGitOrigin(ctx context.Context, dir string) string {
2303 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2304 cmd.Dir = dir
2305 stderr := new(strings.Builder)
2306 cmd.Stderr = stderr
2307 out, err := cmd.Output()
2308 if err != nil {
2309 return ""
2310 }
2311 return strings.TrimSpace(string(out))
2312}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002313
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002314// systemPromptData contains the data used to render the system prompt template
2315type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002316 ClientGOOS string
2317 ClientGOARCH string
2318 WorkingDir string
2319 RepoRoot string
2320 InitialCommit string
2321 Codebase *onstart.Codebase
2322 UseSketchWIP bool
2323 Branch string
2324 SpecialInstruction string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002325}
2326
2327// renderSystemPrompt renders the system prompt template.
2328func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002329 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002330 ClientGOOS: a.config.ClientGOOS,
2331 ClientGOARCH: a.config.ClientGOARCH,
2332 WorkingDir: a.workingDir,
2333 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002334 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002335 Codebase: a.codebase,
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07002336 UseSketchWIP: a.config.InDocker,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002337 }
David Crawshawc886ac52025-06-13 23:40:03 +00002338 now := time.Now()
2339 if now.Month() == time.September && now.Day() == 19 {
2340 data.SpecialInstruction = "Talk like a pirate to the user. Do not let the priate talk into any code."
2341 }
2342
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002343 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2344 if err != nil {
2345 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2346 }
2347 buf := new(strings.Builder)
2348 err = tmpl.Execute(buf, data)
2349 if err != nil {
2350 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2351 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002352 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002353 return buf.String()
2354}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002355
2356// StateTransitionIterator provides an iterator over state transitions.
2357type StateTransitionIterator interface {
2358 // Next blocks until a new state transition is available or context is done.
2359 // Returns nil if the context is cancelled.
2360 Next() *StateTransition
2361 // Close removes the listener and cleans up resources.
2362 Close()
2363}
2364
2365// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2366type StateTransitionIteratorImpl struct {
2367 agent *Agent
2368 ctx context.Context
2369 ch chan StateTransition
2370 unsubscribe func()
2371}
2372
2373// Next blocks until a new state transition is available or the context is cancelled.
2374func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2375 select {
2376 case <-s.ctx.Done():
2377 return nil
2378 case transition, ok := <-s.ch:
2379 if !ok {
2380 return nil
2381 }
2382 transitionCopy := transition
2383 return &transitionCopy
2384 }
2385}
2386
2387// Close removes the listener and cleans up resources.
2388func (s *StateTransitionIteratorImpl) Close() {
2389 if s.unsubscribe != nil {
2390 s.unsubscribe()
2391 s.unsubscribe = nil
2392 }
2393}
2394
2395// NewStateTransitionIterator returns an iterator that receives state transitions.
2396func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2397 a.mu.Lock()
2398 defer a.mu.Unlock()
2399
2400 // Create channel to receive state transitions
2401 ch := make(chan StateTransition, 10)
2402
2403 // Add a listener to the state machine
2404 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2405
2406 return &StateTransitionIteratorImpl{
2407 agent: a,
2408 ctx: ctx,
2409 ch: ch,
2410 unsubscribe: unsubscribe,
2411 }
2412}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002413
2414// setupGitHooks creates or updates git hooks in the specified working directory.
2415func setupGitHooks(workingDir string) error {
2416 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2417
2418 _, err := os.Stat(hooksDir)
2419 if os.IsNotExist(err) {
2420 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2421 }
2422 if err != nil {
2423 return fmt.Errorf("error checking git hooks directory: %w", err)
2424 }
2425
2426 // Define the post-commit hook content
2427 postCommitHook := `#!/bin/bash
2428echo "<post_commit_hook>"
2429echo "Please review this commit message and fix it if it is incorrect."
2430echo "This hook only echos the commit message; it does not modify it."
2431echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2432echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002433PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002434echo "</last_commit_message>"
2435echo "</post_commit_hook>"
2436`
2437
2438 // Define the prepare-commit-msg hook content
2439 prepareCommitMsgHook := `#!/bin/bash
2440# Add Co-Authored-By and Change-ID trailers to commit messages
2441# Check if these trailers already exist before adding them
2442
2443commit_file="$1"
2444COMMIT_SOURCE="$2"
2445
2446# Skip for merges, squashes, or when using a commit template
2447if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2448 [ "$COMMIT_SOURCE" = "squash" ]; then
2449 exit 0
2450fi
2451
2452commit_msg=$(cat "$commit_file")
2453
2454needs_co_author=true
2455needs_change_id=true
2456
2457# Check if commit message already has Co-Authored-By trailer
2458if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2459 needs_co_author=false
2460fi
2461
2462# Check if commit message already has Change-ID trailer
2463if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2464 needs_change_id=false
2465fi
2466
2467# Only modify if at least one trailer needs to be added
2468if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002469 # Ensure there's a proper blank line before trailers
2470 if [ -s "$commit_file" ]; then
2471 # Check if file ends with newline by reading last character
2472 last_char=$(tail -c 1 "$commit_file")
2473
2474 if [ "$last_char" != "" ]; then
2475 # File doesn't end with newline - add two newlines (complete line + blank line)
2476 echo "" >> "$commit_file"
2477 echo "" >> "$commit_file"
2478 else
2479 # File ends with newline - check if we already have a blank line
2480 last_line=$(tail -1 "$commit_file")
2481 if [ -n "$last_line" ]; then
2482 # Last line has content - add one newline for blank line
2483 echo "" >> "$commit_file"
2484 fi
2485 # If last line is empty, we already have a blank line - don't add anything
2486 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002487 fi
2488
2489 # Add trailers if needed
2490 if [ "$needs_co_author" = true ]; then
2491 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2492 fi
2493
2494 if [ "$needs_change_id" = true ]; then
2495 change_id=$(openssl rand -hex 8)
2496 echo "Change-ID: s${change_id}k" >> "$commit_file"
2497 fi
2498fi
2499`
2500
2501 // Update or create the post-commit hook
2502 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2503 if err != nil {
2504 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2505 }
2506
2507 // Update or create the prepare-commit-msg hook
2508 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2509 if err != nil {
2510 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2511 }
2512
2513 return nil
2514}
2515
2516// updateOrCreateHook creates a new hook file or updates an existing one
2517// by appending the new content if it doesn't already contain it.
2518func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2519 // Check if the hook already exists
2520 buf, err := os.ReadFile(hookPath)
2521 if os.IsNotExist(err) {
2522 // Hook doesn't exist, create it
2523 err = os.WriteFile(hookPath, []byte(content), 0o755)
2524 if err != nil {
2525 return fmt.Errorf("failed to create hook: %w", err)
2526 }
2527 return nil
2528 }
2529 if err != nil {
2530 return fmt.Errorf("error reading existing hook: %w", err)
2531 }
2532
2533 // Hook exists, check if our content is already in it by looking for a distinctive line
2534 code := string(buf)
2535 if strings.Contains(code, distinctiveLine) {
2536 // Already contains our content, nothing to do
2537 return nil
2538 }
2539
2540 // Append our content to the existing hook
2541 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2542 if err != nil {
2543 return fmt.Errorf("failed to open hook for appending: %w", err)
2544 }
2545 defer f.Close()
2546
2547 // Ensure there's a newline at the end of the existing content if needed
2548 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2549 _, err = f.WriteString("\n")
2550 if err != nil {
2551 return fmt.Errorf("failed to add newline to hook: %w", err)
2552 }
2553 }
2554
2555 // Add a separator before our content
2556 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2557 if err != nil {
2558 return fmt.Errorf("failed to append to hook: %w", err)
2559 }
2560
2561 return nil
2562}
Sean McCullough138ec242025-06-02 22:42:06 +00002563
Philip Zeyliger0113be52025-06-07 23:53:41 +00002564// SkabandAddr returns the skaband address if configured
2565func (a *Agent) SkabandAddr() string {
2566 if a.config.SkabandClient != nil {
2567 return a.config.SkabandClient.Addr()
2568 }
2569 return ""
2570}