blob: 841a5ca414972fec3be609eae6b30d33a0a13284 [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
banksean5ab8fb82025-07-09 12:34:55 -0700162
163 // TokenContextWindow returns the TokenContextWindow size of the model the agent is using.
164 TokenContextWindow() int
Earl Lee2e463fb2025-04-17 11:22:22 -0700165}
166
167type CodingAgentMessageType string
168
169const (
170 UserMessageType CodingAgentMessageType = "user"
171 AgentMessageType CodingAgentMessageType = "agent"
172 ErrorMessageType CodingAgentMessageType = "error"
173 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
174 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700175 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
176 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
177 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000178 PortMessageType CodingAgentMessageType = "port" // for port monitoring events
Earl Lee2e463fb2025-04-17 11:22:22 -0700179
180 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
181)
182
183type AgentMessage struct {
184 Type CodingAgentMessageType `json:"type"`
185 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
186 EndOfTurn bool `json:"end_of_turn"`
187
188 Content string `json:"content"`
189 ToolName string `json:"tool_name,omitempty"`
190 ToolInput string `json:"input,omitempty"`
191 ToolResult string `json:"tool_result,omitempty"`
192 ToolError bool `json:"tool_error,omitempty"`
193 ToolCallId string `json:"tool_call_id,omitempty"`
194
195 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
196 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
197
Sean McCulloughd9f13372025-04-21 15:08:49 -0700198 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
199 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
200
Earl Lee2e463fb2025-04-17 11:22:22 -0700201 // Commits is a list of git commits for a commit message
202 Commits []*GitCommit `json:"commits,omitempty"`
203
204 Timestamp time.Time `json:"timestamp"`
205 ConversationID string `json:"conversation_id"`
206 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700207 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700208
209 // Message timing information
210 StartTime *time.Time `json:"start_time,omitempty"`
211 EndTime *time.Time `json:"end_time,omitempty"`
212 Elapsed *time.Duration `json:"elapsed,omitempty"`
213
214 // Turn duration - the time taken for a complete agent turn
215 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
216
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000217 // HideOutput indicates that this message should not be rendered in the UI.
218 // This is useful for subconversations that generate output that shouldn't be shown to the user.
219 HideOutput bool `json:"hide_output,omitempty"`
220
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700221 // TodoContent contains the agent's todo file content when it has changed
222 TodoContent *string `json:"todo_content,omitempty"`
223
Earl Lee2e463fb2025-04-17 11:22:22 -0700224 Idx int `json:"idx"`
225}
226
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000227// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700228func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700229 if convo == nil {
230 m.ConversationID = ""
231 m.ParentConversationID = nil
232 return
233 }
234 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000235 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700236 if convo.Parent != nil {
237 m.ParentConversationID = &convo.Parent.ID
238 }
239}
240
Earl Lee2e463fb2025-04-17 11:22:22 -0700241// GitCommit represents a single git commit for a commit message
242type GitCommit struct {
243 Hash string `json:"hash"` // Full commit hash
244 Subject string `json:"subject"` // Commit subject line
245 Body string `json:"body"` // Full commit message body
246 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
247}
248
249// ToolCall represents a single tool call within an agent message
250type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700251 Name string `json:"name"`
252 Input string `json:"input"`
253 ToolCallId string `json:"tool_call_id"`
254 ResultMessage *AgentMessage `json:"result_message,omitempty"`
255 Args string `json:"args,omitempty"`
256 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700257}
258
259func (a *AgentMessage) Attr() slog.Attr {
260 var attrs []any = []any{
261 slog.String("type", string(a.Type)),
262 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700263 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700264 if a.EndOfTurn {
265 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
266 }
267 if a.Content != "" {
268 attrs = append(attrs, slog.String("content", a.Content))
269 }
270 if a.ToolName != "" {
271 attrs = append(attrs, slog.String("tool_name", a.ToolName))
272 }
273 if a.ToolInput != "" {
274 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
275 }
276 if a.Elapsed != nil {
277 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
278 }
279 if a.TurnDuration != nil {
280 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
281 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700282 if len(a.ToolResult) > 0 {
283 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700284 }
285 if a.ToolError {
286 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
287 }
288 if len(a.ToolCalls) > 0 {
289 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
290 for i, tc := range a.ToolCalls {
291 toolCallAttrs = append(toolCallAttrs, slog.Group(
292 fmt.Sprintf("tool_call_%d", i),
293 slog.String("name", tc.Name),
294 slog.String("input", tc.Input),
295 ))
296 }
297 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
298 }
299 if a.ConversationID != "" {
300 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
301 }
302 if a.ParentConversationID != nil {
303 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
304 }
305 if a.Usage != nil && !a.Usage.IsZero() {
306 attrs = append(attrs, a.Usage.Attr())
307 }
308 // TODO: timestamp, convo ids, idx?
309 return slog.Group("agent_message", attrs...)
310}
311
312func errorMessage(err error) AgentMessage {
313 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
314 if os.Getenv(("DEBUG")) == "1" {
315 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
316 }
317
318 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
319}
320
321func budgetMessage(err error) AgentMessage {
322 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
323}
324
325// ConvoInterface defines the interface for conversation interactions
326type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700327 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700328 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700329 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700330 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700331 SendMessage(message llm.Message) (*llm.Response, error)
332 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700333 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000334 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700335 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700336 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700337 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700338}
339
Philip Zeyligerf2872992025-05-22 10:35:28 -0700340// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700341// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700342// any time we notice we need to.
343type AgentGitState struct {
344 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700345 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700346 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000347 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700348 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700349 slug string // Human-readable session identifier
350 retryNumber int // Number to append when branch conflicts occur
Philip Zeyliger64f60462025-06-16 13:57:10 -0700351 linesAdded int // Lines added from sketch-base to HEAD
352 linesRemoved int // Lines removed from sketch-base to HEAD
Philip Zeyligerf2872992025-05-22 10:35:28 -0700353}
354
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700355func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700356 ags.mu.Lock()
357 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700358 if ags.slug != slug {
359 ags.retryNumber = 0
360 }
361 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700362}
363
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700364func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700365 ags.mu.Lock()
366 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700367 return ags.slug
368}
369
370func (ags *AgentGitState) IncrementRetryNumber() {
371 ags.mu.Lock()
372 defer ags.mu.Unlock()
373 ags.retryNumber++
374}
375
Philip Zeyliger64f60462025-06-16 13:57:10 -0700376func (ags *AgentGitState) DiffStats() (int, int) {
377 ags.mu.Lock()
378 defer ags.mu.Unlock()
379 return ags.linesAdded, ags.linesRemoved
380}
381
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700382// HasSeenCommits returns true if any commits have been processed
383func (ags *AgentGitState) HasSeenCommits() bool {
384 ags.mu.Lock()
385 defer ags.mu.Unlock()
386 return len(ags.seenCommits) > 0
387}
388
389func (ags *AgentGitState) RetryNumber() int {
390 ags.mu.Lock()
391 defer ags.mu.Unlock()
392 return ags.retryNumber
393}
394
395func (ags *AgentGitState) BranchName(prefix string) string {
396 ags.mu.Lock()
397 defer ags.mu.Unlock()
398 return ags.branchNameLocked(prefix)
399}
400
401func (ags *AgentGitState) branchNameLocked(prefix string) string {
402 if ags.slug == "" {
403 return ""
404 }
405 if ags.retryNumber == 0 {
406 return prefix + ags.slug
407 }
408 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700409}
410
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000411func (ags *AgentGitState) Upstream() string {
412 ags.mu.Lock()
413 defer ags.mu.Unlock()
414 return ags.upstream
415}
416
Earl Lee2e463fb2025-04-17 11:22:22 -0700417type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700418 convo ConvoInterface
419 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700420 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700421 workingDir string
422 repoRoot string // workingDir may be a subdir of repoRoot
423 url string
424 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000425 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700426 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000427 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700428 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700429 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000430 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700431 // State machine to track agent state
432 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000433 // Outside information
434 outsideHostname string
435 outsideOS string
436 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000437 // URL of the git remote 'origin' if it exists
438 gitOrigin string
Philip Zeyliger194bfa82025-06-24 06:03:06 -0700439 // MCP manager for handling MCP server connections
440 mcpManager *mcp.MCPManager
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000441 // Port monitor for tracking TCP ports
442 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700443
444 // Time when the current turn started (reset at the beginning of InnerLoop)
445 startOfTurn time.Time
446
447 // Inbox - for messages from the user to the agent.
448 // sent on by UserMessage
449 // . e.g. when user types into the chat textarea
450 // read from by GatherMessages
451 inbox chan string
452
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000453 // protects cancelTurn
454 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700455 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000456 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700457
458 // protects following
459 mu sync.Mutex
460
461 // Stores all messages for this agent
462 history []AgentMessage
463
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700464 // Iterators add themselves here when they're ready to be notified of new messages.
465 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700466
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000467 // Track outstanding LLM call IDs
468 outstandingLLMCalls map[string]struct{}
469
470 // Track outstanding tool calls by ID with their names
471 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700472}
473
banksean5ab8fb82025-07-09 12:34:55 -0700474// TokenContextWindow implements CodingAgent.
475func (a *Agent) TokenContextWindow() int {
476 return a.config.Service.TokenContextWindow()
477}
478
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700479// NewIterator implements CodingAgent.
480func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
481 a.mu.Lock()
482 defer a.mu.Unlock()
483
484 return &MessageIteratorImpl{
485 agent: a,
486 ctx: ctx,
487 nextMessageIdx: nextMessageIdx,
488 ch: make(chan *AgentMessage, 100),
489 }
490}
491
492type MessageIteratorImpl struct {
493 agent *Agent
494 ctx context.Context
495 nextMessageIdx int
496 ch chan *AgentMessage
497 subscribed bool
498}
499
500func (m *MessageIteratorImpl) Close() {
501 m.agent.mu.Lock()
502 defer m.agent.mu.Unlock()
503 // Delete ourselves from the subscribers list
504 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
505 return x == m.ch
506 })
507 close(m.ch)
508}
509
510func (m *MessageIteratorImpl) Next() *AgentMessage {
511 // We avoid subscription at creation to let ourselves catch up to "current state"
512 // before subscribing.
513 if !m.subscribed {
514 m.agent.mu.Lock()
515 if m.nextMessageIdx < len(m.agent.history) {
516 msg := &m.agent.history[m.nextMessageIdx]
517 m.nextMessageIdx++
518 m.agent.mu.Unlock()
519 return msg
520 }
521 // The next message doesn't exist yet, so let's subscribe
522 m.agent.subscribers = append(m.agent.subscribers, m.ch)
523 m.subscribed = true
524 m.agent.mu.Unlock()
525 }
526
527 for {
528 select {
529 case <-m.ctx.Done():
530 m.agent.mu.Lock()
531 // Delete ourselves from the subscribers list
532 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
533 return x == m.ch
534 })
535 m.subscribed = false
536 m.agent.mu.Unlock()
537 return nil
538 case msg, ok := <-m.ch:
539 if !ok {
540 // Close may have been called
541 return nil
542 }
543 if msg.Idx == m.nextMessageIdx {
544 m.nextMessageIdx++
545 return msg
546 }
547 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
548 panic("out of order message")
549 }
550 }
551}
552
Sean McCulloughd9d45812025-04-30 16:53:41 -0700553// Assert that Agent satisfies the CodingAgent interface.
554var _ CodingAgent = &Agent{}
555
556// StateName implements CodingAgent.
557func (a *Agent) CurrentStateName() string {
558 if a.stateMachine == nil {
559 return ""
560 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000561 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700562}
563
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700564// CurrentTodoContent returns the current todo list data as JSON.
565// It returns an empty string if no todos exist.
566func (a *Agent) CurrentTodoContent() string {
567 todoPath := claudetool.TodoFilePath(a.config.SessionID)
568 content, err := os.ReadFile(todoPath)
569 if err != nil {
570 return ""
571 }
572 return string(content)
573}
574
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700575// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
576func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
577 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.
578
579IMPORTANT: 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.
580
581Please create a detailed summary that includes:
582
5831. **User's Request**: What did the user originally ask me to do? What was their goal?
584
5852. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
586
5873. **Key Technical Decisions**: What important technical choices were made during our work and why?
588
5894. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
590
5915. **Next Steps**: What still needs to be done to complete the user's request?
592
5936. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
594
595Focus 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.
596
597Reply with ONLY the summary content - no meta-commentary about creating the summary.`
598
599 userMessage := llm.UserStringMessage(msg)
600 // Use a subconversation with history to get the summary
601 // TODO: We don't have any tools here, so we should have enough tokens
602 // to capture a summary, but we may need to modify the history (e.g., remove
603 // TODO data) to save on some tokens.
604 convo := a.convo.SubConvoWithHistory()
605
606 // Modify the system prompt to provide context about the original task
607 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000608 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 -0700609
610Your 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.
611
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000612Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700613
614 resp, err := convo.SendMessage(userMessage)
615 if err != nil {
616 a.pushToOutbox(ctx, errorMessage(err))
617 return "", err
618 }
619 textContent := collectTextContent(resp)
620
621 // Restore original system prompt (though this subconvo will be discarded)
622 convo.SystemPrompt = originalSystemPrompt
623
624 return textContent, nil
625}
626
627// CompactConversation compacts the current conversation by generating a summary
628// and restarting the conversation with that summary as the initial context
629func (a *Agent) CompactConversation(ctx context.Context) error {
630 summary, err := a.generateConversationSummary(ctx)
631 if err != nil {
632 return fmt.Errorf("failed to generate conversation summary: %w", err)
633 }
634
635 a.mu.Lock()
636
637 // Get usage information before resetting conversation
638 lastUsage := a.convo.LastUsage()
639 contextWindow := a.config.Service.TokenContextWindow()
640 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
641
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000642 // Preserve cumulative usage across compaction
643 cumulativeUsage := a.convo.CumulativeUsage()
644
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700645 // Reset conversation state but keep all other state (git, working dir, etc.)
646 a.firstMessageIndex = len(a.history)
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000647 a.convo = a.initConvoWithUsage(&cumulativeUsage)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700648
649 a.mu.Unlock()
650
651 // Create informative compaction message with token details
652 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
653 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
654 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
655
656 a.pushToOutbox(ctx, AgentMessage{
657 Type: CompactMessageType,
658 Content: compactionMsg,
659 })
660
661 a.pushToOutbox(ctx, AgentMessage{
662 Type: UserMessageType,
663 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),
664 })
665 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)
666
667 return nil
668}
669
Earl Lee2e463fb2025-04-17 11:22:22 -0700670func (a *Agent) URL() string { return a.url }
671
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000672// GetPorts returns the cached list of open TCP ports.
673func (a *Agent) GetPorts() []portlist.Port {
674 if a.portMonitor == nil {
675 return nil
676 }
677 return a.portMonitor.GetPorts()
678}
679
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000680// BranchName returns the git branch name for the conversation.
681func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700682 return a.gitState.BranchName(a.config.BranchPrefix)
683}
684
685// Slug returns the slug identifier for this conversation.
686func (a *Agent) Slug() string {
687 return a.gitState.Slug()
688}
689
690// IncrementRetryNumber increments the retry number for branch naming conflicts
691func (a *Agent) IncrementRetryNumber() {
692 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000693}
694
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000695// OutstandingLLMCallCount returns the number of outstanding LLM calls.
696func (a *Agent) OutstandingLLMCallCount() int {
697 a.mu.Lock()
698 defer a.mu.Unlock()
699 return len(a.outstandingLLMCalls)
700}
701
702// OutstandingToolCalls returns the names of outstanding tool calls.
703func (a *Agent) OutstandingToolCalls() []string {
704 a.mu.Lock()
705 defer a.mu.Unlock()
706
707 tools := make([]string, 0, len(a.outstandingToolCalls))
708 for _, toolName := range a.outstandingToolCalls {
709 tools = append(tools, toolName)
710 }
711 return tools
712}
713
Earl Lee2e463fb2025-04-17 11:22:22 -0700714// OS returns the operating system of the client.
715func (a *Agent) OS() string {
716 return a.config.ClientGOOS
717}
718
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000719func (a *Agent) SessionID() string {
720 return a.config.SessionID
721}
722
philip.zeyliger8773e682025-06-11 21:36:21 -0700723// SSHConnectionString returns the SSH connection string for the container.
724func (a *Agent) SSHConnectionString() string {
725 return a.config.SSHConnectionString
726}
727
Philip Zeyliger18532b22025-04-23 21:11:46 +0000728// OutsideOS returns the operating system of the outside system.
729func (a *Agent) OutsideOS() string {
730 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000731}
732
Philip Zeyliger18532b22025-04-23 21:11:46 +0000733// OutsideHostname returns the hostname of the outside system.
734func (a *Agent) OutsideHostname() string {
735 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000736}
737
Philip Zeyliger18532b22025-04-23 21:11:46 +0000738// OutsideWorkingDir returns the working directory on the outside system.
739func (a *Agent) OutsideWorkingDir() string {
740 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000741}
742
743// GitOrigin returns the URL of the git remote 'origin' if it exists.
744func (a *Agent) GitOrigin() string {
745 return a.gitOrigin
746}
747
bankseancad67b02025-06-27 21:57:05 +0000748// GitUsername returns the git user name from the agent config.
749func (a *Agent) GitUsername() string {
750 return a.config.GitUsername
751}
752
Philip Zeyliger64f60462025-06-16 13:57:10 -0700753// DiffStats returns the number of lines added and removed from sketch-base to HEAD
754func (a *Agent) DiffStats() (int, int) {
755 return a.gitState.DiffStats()
756}
757
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000758func (a *Agent) OpenBrowser(url string) {
759 if !a.IsInContainer() {
760 browser.Open(url)
761 return
762 }
763 // We're in Docker, need to send a request to the Git server
764 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700765 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000766 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700767 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000768 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700769 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000770 return
771 }
772 defer resp.Body.Close()
773 if resp.StatusCode == http.StatusOK {
774 return
775 }
776 body, _ := io.ReadAll(resp.Body)
777 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
778}
779
Sean McCullough96b60dd2025-04-30 09:49:10 -0700780// CurrentState returns the current state of the agent's state machine.
781func (a *Agent) CurrentState() State {
782 return a.stateMachine.CurrentState()
783}
784
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700785func (a *Agent) IsInContainer() bool {
786 return a.config.InDocker
787}
788
789func (a *Agent) FirstMessageIndex() int {
790 a.mu.Lock()
791 defer a.mu.Unlock()
792 return a.firstMessageIndex
793}
794
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700795// SetSlug sets a human-readable identifier for the conversation.
796func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700797 a.mu.Lock()
798 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700799
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700800 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000801 convo, ok := a.convo.(*conversation.Convo)
802 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700803 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000804 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700805}
806
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000807// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700808func (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 +0000809 // Track the tool call
810 a.mu.Lock()
811 a.outstandingToolCalls[id] = toolName
812 a.mu.Unlock()
813}
814
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700815// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
816// If there's only one element in the array and it's a text type, it returns that text directly.
817// It also processes nested ToolResult arrays recursively.
818func contentToString(contents []llm.Content) string {
819 if len(contents) == 0 {
820 return ""
821 }
822
823 // If there's only one element and it's a text type, return it directly
824 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
825 return contents[0].Text
826 }
827
828 // Otherwise, concatenate all text content
829 var result strings.Builder
830 for _, content := range contents {
831 if content.Type == llm.ContentTypeText {
832 result.WriteString(content.Text)
833 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
834 // Recursively process nested tool results
835 result.WriteString(contentToString(content.ToolResult))
836 }
837 }
838
839 return result.String()
840}
841
Earl Lee2e463fb2025-04-17 11:22:22 -0700842// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700843func (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 +0000844 // Remove the tool call from outstanding calls
845 a.mu.Lock()
846 delete(a.outstandingToolCalls, toolID)
847 a.mu.Unlock()
848
Earl Lee2e463fb2025-04-17 11:22:22 -0700849 m := AgentMessage{
850 Type: ToolUseMessageType,
851 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700852 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700853 ToolError: content.ToolError,
854 ToolName: toolName,
855 ToolInput: string(toolInput),
856 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700857 StartTime: content.ToolUseStartTime,
858 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700859 }
860
861 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700862 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
863 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700864 m.Elapsed = &elapsed
865 }
866
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700867 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700868 a.pushToOutbox(ctx, m)
869}
870
871// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700872func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000873 a.mu.Lock()
874 defer a.mu.Unlock()
875 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700876 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
877}
878
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700879// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700880// that need to be displayed (as well as tool calls that we send along when
881// they're done). (It would be reasonable to also mention tool calls when they're
882// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700883func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000884 // Remove the LLM call from outstanding calls
885 a.mu.Lock()
886 delete(a.outstandingLLMCalls, id)
887 a.mu.Unlock()
888
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700889 if resp == nil {
890 // LLM API call failed
891 m := AgentMessage{
892 Type: ErrorMessageType,
893 Content: "API call failed, type 'continue' to try again",
894 }
895 m.SetConvo(convo)
896 a.pushToOutbox(ctx, m)
897 return
898 }
899
Earl Lee2e463fb2025-04-17 11:22:22 -0700900 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700901 if convo.Parent == nil { // subconvos never end the turn
902 switch resp.StopReason {
903 case llm.StopReasonToolUse:
904 // Check whether any of the tool calls are for tools that should end the turn
905 ToolSearch:
906 for _, part := range resp.Content {
907 if part.Type != llm.ContentTypeToolUse {
908 continue
909 }
Sean McCullough021557a2025-05-05 23:20:53 +0000910 // Find the tool by name
911 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700912 if tool.Name == part.ToolName {
913 endOfTurn = tool.EndsTurn
914 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000915 }
916 }
Sean McCullough021557a2025-05-05 23:20:53 +0000917 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700918 default:
919 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000920 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700921 }
922 m := AgentMessage{
923 Type: AgentMessageType,
924 Content: collectTextContent(resp),
925 EndOfTurn: endOfTurn,
926 Usage: &resp.Usage,
927 StartTime: resp.StartTime,
928 EndTime: resp.EndTime,
929 }
930
931 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700932 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700933 var toolCalls []ToolCall
934 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700935 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700936 toolCalls = append(toolCalls, ToolCall{
937 Name: part.ToolName,
938 Input: string(part.ToolInput),
939 ToolCallId: part.ID,
940 })
941 }
942 }
943 m.ToolCalls = toolCalls
944 }
945
946 // Calculate the elapsed time if both start and end times are set
947 if resp.StartTime != nil && resp.EndTime != nil {
948 elapsed := resp.EndTime.Sub(*resp.StartTime)
949 m.Elapsed = &elapsed
950 }
951
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700952 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700953 a.pushToOutbox(ctx, m)
954}
955
956// WorkingDir implements CodingAgent.
957func (a *Agent) WorkingDir() string {
958 return a.workingDir
959}
960
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000961// RepoRoot returns the git repository root directory.
962func (a *Agent) RepoRoot() string {
963 return a.repoRoot
964}
965
Earl Lee2e463fb2025-04-17 11:22:22 -0700966// MessageCount implements CodingAgent.
967func (a *Agent) MessageCount() int {
968 a.mu.Lock()
969 defer a.mu.Unlock()
970 return len(a.history)
971}
972
973// Messages implements CodingAgent.
974func (a *Agent) Messages(start int, end int) []AgentMessage {
975 a.mu.Lock()
976 defer a.mu.Unlock()
977 return slices.Clone(a.history[start:end])
978}
979
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700980// ShouldCompact checks if the conversation should be compacted based on token usage
981func (a *Agent) ShouldCompact() bool {
982 // Get the threshold from environment variable, default to 0.94 (94%)
983 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
984 // and a little bit of buffer.)
985 thresholdRatio := 0.94
986 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
987 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
988 thresholdRatio = parsed
989 }
990 }
991
992 // Get the most recent usage to check current context size
993 lastUsage := a.convo.LastUsage()
994
995 if lastUsage.InputTokens == 0 {
996 // No API calls made yet
997 return false
998 }
999
1000 // Calculate the current context size from the last API call
1001 // This includes all tokens that were part of the input context:
1002 // - Input tokens (user messages, system prompt, conversation history)
1003 // - Cache read tokens (cached parts of the context)
1004 // - Cache creation tokens (new parts being cached)
1005 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
1006
1007 // Get the service's token context window
1008 service := a.config.Service
1009 contextWindow := service.TokenContextWindow()
1010
1011 // Calculate threshold
1012 threshold := uint64(float64(contextWindow) * thresholdRatio)
1013
1014 // Check if we've exceeded the threshold
1015 return currentContextSize >= threshold
1016}
1017
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001018func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -07001019 return a.originalBudget
1020}
1021
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001022// Upstream returns the upstream branch for git work
1023func (a *Agent) Upstream() string {
1024 return a.gitState.Upstream()
1025}
1026
Earl Lee2e463fb2025-04-17 11:22:22 -07001027// AgentConfig contains configuration for creating a new Agent.
1028type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001029 Context context.Context
1030 Service llm.Service
1031 Budget conversation.Budget
1032 GitUsername string
1033 GitEmail string
1034 SessionID string
1035 ClientGOOS string
1036 ClientGOARCH string
1037 InDocker bool
1038 OneShot bool
1039 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +00001040 // Outside information
1041 OutsideHostname string
1042 OutsideOS string
1043 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001044
1045 // Outtie's HTTP to, e.g., open a browser
1046 OutsideHTTP string
1047 // Outtie's Git server
1048 GitRemoteAddr string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001049 // Upstream branch for git work
1050 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001051 // Commit to checkout from Outtie
1052 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001053 // Prefix for git branches created by sketch
1054 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001055 // LinkToGitHub enables GitHub branch linking in UI
1056 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001057 // SSH connection string for connecting to the container
1058 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001059 // Skaband client for session history (optional)
1060 SkabandClient *skabandclient.SkabandClient
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001061 // MCP server configurations
1062 MCPServers []string
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001063 // Timeout configuration for bash tool
1064 BashTimeouts *claudetool.Timeouts
Earl Lee2e463fb2025-04-17 11:22:22 -07001065}
1066
1067// NewAgent creates a new Agent.
1068// It is not usable until Init() is called.
1069func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001070 // Set default branch prefix if not specified
1071 if config.BranchPrefix == "" {
1072 config.BranchPrefix = "sketch/"
1073 }
1074
Earl Lee2e463fb2025-04-17 11:22:22 -07001075 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001076 config: config,
1077 ready: make(chan struct{}),
1078 inbox: make(chan string, 100),
1079 subscribers: make([]chan *AgentMessage, 0),
1080 startedAt: time.Now(),
1081 originalBudget: config.Budget,
1082 gitState: AgentGitState{
1083 seenCommits: make(map[string]bool),
1084 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001085 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001086 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001087 outsideHostname: config.OutsideHostname,
1088 outsideOS: config.OutsideOS,
1089 outsideWorkingDir: config.OutsideWorkingDir,
1090 outstandingLLMCalls: make(map[string]struct{}),
1091 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001092 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001093 workingDir: config.WorkingDir,
1094 outsideHTTP: config.OutsideHTTP,
Philip Zeyligerda623b52025-07-04 01:12:38 +00001095
1096 mcpManager: mcp.NewMCPManager(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001097 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001098
1099 // Initialize port monitor with 5-second interval
1100 agent.portMonitor = NewPortMonitor(agent, 5*time.Second)
1101
Earl Lee2e463fb2025-04-17 11:22:22 -07001102 return agent
1103}
1104
1105type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001106 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001107
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001108 InDocker bool
1109 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001110}
1111
1112func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001113 if a.convo != nil {
1114 return fmt.Errorf("Agent.Init: already initialized")
1115 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001116 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001117 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001118
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001119 if !ini.NoGit {
1120 // Capture the original origin before we potentially replace it below
1121 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
Philip Zeyligere1c8b7b2025-07-03 14:50:26 -07001122
1123 // Configure git user settings
1124 if a.config.GitEmail != "" {
1125 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.email", a.config.GitEmail)
1126 cmd.Dir = a.workingDir
1127 if out, err := cmd.CombinedOutput(); err != nil {
1128 return fmt.Errorf("git config --global user.email: %s: %v", out, err)
1129 }
1130 }
1131 if a.config.GitUsername != "" {
1132 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.name", a.config.GitUsername)
1133 cmd.Dir = a.workingDir
1134 if out, err := cmd.CombinedOutput(); err != nil {
1135 return fmt.Errorf("git config --global user.name: %s: %v", out, err)
1136 }
1137 }
1138 // Configure git http.postBuffer
1139 cmd := exec.CommandContext(ctx, "git", "config", "--global", "http.postBuffer", "524288000")
1140 cmd.Dir = a.workingDir
1141 if out, err := cmd.CombinedOutput(); err != nil {
1142 return fmt.Errorf("git config --global http.postBuffer: %s: %v", out, err)
1143 }
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001144 }
1145
Philip Zeyliger222bf412025-06-04 16:42:58 +00001146 // If a remote git addr was specified, we configure the origin remote
Philip Zeyligerf2872992025-05-22 10:35:28 -07001147 if a.gitState.gitRemoteAddr != "" {
1148 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
Philip Zeyliger222bf412025-06-04 16:42:58 +00001149
1150 // Remove existing origin remote if it exists
1151 cmd := exec.CommandContext(ctx, "git", "remote", "remove", "origin")
Philip Zeyligerf2872992025-05-22 10:35:28 -07001152 cmd.Dir = a.workingDir
1153 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001154 // Ignore error if origin doesn't exist
1155 slog.DebugContext(ctx, "git remote remove origin (ignoring if not exists)", slog.String("output", string(out)))
Philip Zeyligerf2872992025-05-22 10:35:28 -07001156 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001157
1158 // Add the new remote as origin
1159 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", a.gitState.gitRemoteAddr)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001160 cmd.Dir = a.workingDir
1161 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001162 return fmt.Errorf("git remote add origin: %s: %v", out, err)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001163 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001164
Philip Zeyligerf2872992025-05-22 10:35:28 -07001165 }
1166
1167 // If a commit was specified, we fetch and reset to it.
1168 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001169 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1170
Earl Lee2e463fb2025-04-17 11:22:22 -07001171 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001172 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001173 if out, err := cmd.CombinedOutput(); err != nil {
1174 return fmt.Errorf("git stash: %s: %v", out, err)
1175 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001176 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001177 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001178 if out, err := cmd.CombinedOutput(); err != nil {
1179 return fmt.Errorf("git fetch: %s: %w", out, err)
1180 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001181 // The -B resets the branch if it already exists (or creates it if it doesn't)
1182 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001183 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001184 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1185 // Remove git hooks if they exist and retry
1186 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001187 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001188 if _, statErr := os.Stat(hookPath); statErr == nil {
1189 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1190 slog.String("error", err.Error()),
1191 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001192 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001193 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1194 }
1195
1196 // Retry the checkout operation
Philip Zeyliger1417b692025-06-12 11:07:04 -07001197 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001198 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001199 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001200 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 +01001201 }
1202 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001203 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001204 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001205 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001206 } else if a.IsInContainer() {
1207 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1208 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1209 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1210 cmd.Dir = a.workingDir
1211 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1212 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1213 }
1214 } else {
1215 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001216 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001217
1218 if ini.HostAddr != "" {
1219 a.url = "http://" + ini.HostAddr
1220 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001221
1222 if !ini.NoGit {
1223 repoRoot, err := repoRoot(ctx, a.workingDir)
1224 if err != nil {
1225 return fmt.Errorf("repoRoot: %w", err)
1226 }
1227 a.repoRoot = repoRoot
1228
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001229 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001230 if err := setupGitHooks(a.repoRoot); err != nil {
1231 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1232 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001233 }
1234
Philip Zeyliger49edc922025-05-14 09:45:45 -07001235 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1236 cmd.Dir = repoRoot
1237 if out, err := cmd.CombinedOutput(); err != nil {
1238 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1239 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001240
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001241 slog.Info("running codebase analysis")
1242 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1243 if err != nil {
1244 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001245 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001246 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001247
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001248 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001249 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001250 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001251 }
1252 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001253
Earl Lee2e463fb2025-04-17 11:22:22 -07001254 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001255 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001256 a.convo = a.initConvo()
1257 close(a.ready)
1258 return nil
1259}
1260
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001261//go:embed agent_system_prompt.txt
1262var agentSystemPrompt string
1263
Earl Lee2e463fb2025-04-17 11:22:22 -07001264// initConvo initializes the conversation.
1265// It must not be called until all agent fields are initialized,
1266// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001267func (a *Agent) initConvo() *conversation.Convo {
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001268 return a.initConvoWithUsage(nil)
1269}
1270
1271// initConvoWithUsage initializes the conversation with optional preserved usage.
1272func (a *Agent) initConvoWithUsage(usage *conversation.CumulativeUsage) *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001273 ctx := a.config.Context
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001274 convo := conversation.New(ctx, a.config.Service, usage)
Earl Lee2e463fb2025-04-17 11:22:22 -07001275 convo.PromptCaching = true
1276 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001277 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001278 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001279
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001280 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1281 bashPermissionCheck := func(command string) error {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001282 if a.gitState.Slug() != "" {
1283 return nil // branch is set up
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001284 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001285 willCommit, err := bashkit.WillRunGitCommit(command)
1286 if err != nil {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001287 return nil // fail open
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001288 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001289 if willCommit {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001290 return fmt.Errorf("you must use the set-slug tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001291 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001292 return nil
1293 }
1294
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001295 bashTool := &claudetool.BashTool{
1296 CheckPermission: bashPermissionCheck,
1297 EnableJITInstall: claudetool.EnableBashToolJITInstall,
1298 Timeouts: a.config.BashTimeouts,
1299 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001300
Earl Lee2e463fb2025-04-17 11:22:22 -07001301 // Register all tools with the conversation
1302 // When adding, removing, or modifying tools here, double-check that the termui tool display
1303 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001304
1305 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001306 _, supportsScreenshots := a.config.Service.(*ant.Service)
1307 var bTools []*llm.Tool
1308 var browserCleanup func()
1309
1310 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1311 // Add cleanup function to context cancel
1312 go func() {
1313 <-a.config.Context.Done()
1314 browserCleanup()
1315 }()
1316 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001317
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001318 convo.Tools = []*llm.Tool{
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001319 bashTool.Tool(), claudetool.Keyword, claudetool.Patch(a.patchCallback),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001320 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.setSlugTool(), a.commitMessageStyleTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001321 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001322 }
1323
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001324 // One-shot mode is non-interactive, multiple choice requires human response
1325 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001326 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001327 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001328
1329 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001330
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001331 // Add MCP tools if configured
1332 if len(a.config.MCPServers) > 0 {
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001333
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001334 slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers))
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001335 serverConfigs, parseErrors := mcp.ParseServerConfigs(ctx, a.config.MCPServers)
1336
1337 // Replace any headers with value _sketch_public_key_ and _sketch_session_id_ with those values.
1338 for i := range serverConfigs {
1339 if serverConfigs[i].Headers != nil {
1340 for key, value := range serverConfigs[i].Headers {
Philip Zeyligerf2814ea2025-06-30 10:16:50 -07001341 // Replace env placeholders. E.g., "env:FOO" becomes os.Getenv("FOO")
1342 if strings.HasPrefix(value, "env:") {
1343 serverConfigs[i].Headers[key] = os.Getenv(value[4:])
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001344 }
1345 }
1346 }
1347 }
1348 mcpConnections, mcpErrors := a.mcpManager.ConnectToServerConfigs(ctx, serverConfigs, 10*time.Second, parseErrors)
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001349
1350 if len(mcpErrors) > 0 {
1351 for _, err := range mcpErrors {
1352 slog.ErrorContext(ctx, "MCP connection error", "error", err)
1353 // Send agent message about MCP connection failures
1354 a.pushToOutbox(ctx, AgentMessage{
1355 Type: ErrorMessageType,
1356 Content: fmt.Sprintf("MCP server connection failed: %v", err),
1357 })
1358 }
1359 }
1360
1361 if len(mcpConnections) > 0 {
1362 // Add tools from all successful connections
1363 totalTools := 0
1364 for _, connection := range mcpConnections {
1365 convo.Tools = append(convo.Tools, connection.Tools...)
1366 totalTools += len(connection.Tools)
1367 // Log tools per server using structured data
1368 slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames)
1369 }
1370 slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools)
1371 } else {
1372 slog.InfoContext(ctx, "No MCP tools available after connection attempts")
1373 }
1374 }
1375
Earl Lee2e463fb2025-04-17 11:22:22 -07001376 convo.Listener = a
1377 return convo
1378}
1379
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001380var multipleChoiceTool = &llm.Tool{
1381 Name: "multiplechoice",
1382 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.",
1383 EndsTurn: true,
1384 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001385 "type": "object",
1386 "description": "The question and a list of answers you would expect the user to choose from.",
1387 "properties": {
1388 "question": {
1389 "type": "string",
1390 "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?'"
1391 },
1392 "responseOptions": {
1393 "type": "array",
1394 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1395 "items": {
1396 "type": "object",
1397 "properties": {
1398 "caption": {
1399 "type": "string",
1400 "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'"
1401 },
1402 "responseText": {
1403 "type": "string",
1404 "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'"
1405 }
1406 },
1407 "required": ["caption", "responseText"]
1408 }
1409 }
1410 },
1411 "required": ["question", "responseOptions"]
1412}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001413 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1414 // The Run logic for "multiplechoice" tool is a no-op on the server.
1415 // The UI will present a list of options for the user to select from,
1416 // and that's it as far as "executing" the tool_use goes.
1417 // When the user *does* select one of the presented options, that
1418 // responseText gets sent as a chat message on behalf of the user.
1419 return llm.TextContent("end your turn and wait for the user to respond"), nil
1420 },
Sean McCullough485afc62025-04-28 14:28:39 -07001421}
1422
1423type MultipleChoiceOption struct {
1424 Caption string `json:"caption"`
1425 ResponseText string `json:"responseText"`
1426}
1427
1428type MultipleChoiceParams struct {
1429 Question string `json:"question"`
1430 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1431}
1432
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001433// branchExists reports whether branchName exists, either locally or in well-known remotes.
1434func branchExists(dir, branchName string) bool {
1435 refs := []string{
1436 "refs/heads/",
1437 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001438 }
1439 for _, ref := range refs {
1440 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1441 cmd.Dir = dir
1442 if cmd.Run() == nil { // exit code 0 means branch exists
1443 return true
1444 }
1445 }
1446 return false
1447}
1448
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001449func (a *Agent) setSlugTool() *llm.Tool {
1450 return &llm.Tool{
1451 Name: "set-slug",
1452 Description: `Set a short slug as an identifier for this conversation.`,
Earl Lee2e463fb2025-04-17 11:22:22 -07001453 InputSchema: json.RawMessage(`{
1454 "type": "object",
1455 "properties": {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001456 "slug": {
Earl Lee2e463fb2025-04-17 11:22:22 -07001457 "type": "string",
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001458 "description": "A 2-3 word alphanumeric hyphenated slug, imperative tense"
Earl Lee2e463fb2025-04-17 11:22:22 -07001459 }
1460 },
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001461 "required": ["slug"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001462}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001463 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001464 var params struct {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001465 Slug string `json:"slug"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001466 }
1467 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001468 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001469 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001470 // Prevent slug changes if there have been git changes
1471 // This lets the agent change its mind about a good slug,
1472 // while ensuring that once a branch has been pushed, it remains stable.
1473 if s := a.Slug(); s != "" && s != params.Slug && a.gitState.HasSeenCommits() {
1474 return nil, fmt.Errorf("slug already set to %q", s)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001475 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001476 if params.Slug == "" {
1477 return nil, fmt.Errorf("slug parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001478 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001479 slug := cleanSlugName(params.Slug)
1480 if slug == "" {
1481 return nil, fmt.Errorf("slug parameter could not be converted to a valid slug")
1482 }
1483 a.SetSlug(slug)
1484 // TODO: do this by a call to outie, rather than semi-guessing from innie
1485 if branchExists(a.workingDir, a.BranchName()) {
1486 return nil, fmt.Errorf("slug %q already exists; please choose a different slug", slug)
1487 }
1488 return llm.TextContent("OK"), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001489 },
1490 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001491}
1492
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001493func (a *Agent) commitMessageStyleTool() *llm.Tool {
1494 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 +00001495 preCommit := &llm.Tool{
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001496 Name: "commit-message-style",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001497 Description: description,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001498 InputSchema: llm.EmptySchema(),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001499 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001500 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1501 if err != nil {
1502 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1503 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001504 return llm.TextContent(styleHint), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001505 },
1506 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001507 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001508}
1509
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001510// patchCallback is the agent's patch tool callback.
1511// It warms the codereview cache in the background.
1512func (a *Agent) patchCallback(input claudetool.PatchInput, result []llm.Content, err error) ([]llm.Content, error) {
1513 if a.codereview != nil {
1514 a.codereview.WarmTestCache(input.Path)
1515 }
1516 return result, err
1517}
1518
Earl Lee2e463fb2025-04-17 11:22:22 -07001519func (a *Agent) Ready() <-chan struct{} {
1520 return a.ready
1521}
1522
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001523// BranchPrefix returns the configured branch prefix
1524func (a *Agent) BranchPrefix() string {
1525 return a.config.BranchPrefix
1526}
1527
philip.zeyliger6d3de482025-06-10 19:38:14 -07001528// LinkToGitHub returns whether GitHub branch linking is enabled
1529func (a *Agent) LinkToGitHub() bool {
1530 return a.config.LinkToGitHub
1531}
1532
Earl Lee2e463fb2025-04-17 11:22:22 -07001533func (a *Agent) UserMessage(ctx context.Context, msg string) {
1534 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1535 a.inbox <- msg
1536}
1537
Earl Lee2e463fb2025-04-17 11:22:22 -07001538func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1539 return a.convo.CancelToolUse(toolUseID, cause)
1540}
1541
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001542func (a *Agent) CancelTurn(cause error) {
1543 a.cancelTurnMu.Lock()
1544 defer a.cancelTurnMu.Unlock()
1545 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001546 // Force state transition to cancelled state
1547 ctx := a.config.Context
1548 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001549 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001550 }
1551}
1552
1553func (a *Agent) Loop(ctxOuter context.Context) {
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001554 // Start port monitoring
1555 if a.portMonitor != nil && a.IsInContainer() {
1556 if err := a.portMonitor.Start(ctxOuter); err != nil {
1557 slog.WarnContext(ctxOuter, "Failed to start port monitor", "error", err)
1558 } else {
1559 slog.InfoContext(ctxOuter, "Port monitor started")
1560 }
1561 }
1562
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001563 // Set up cleanup when context is done
1564 defer func() {
1565 if a.mcpManager != nil {
1566 a.mcpManager.Close()
1567 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001568 if a.portMonitor != nil && a.IsInContainer() {
1569 a.portMonitor.Stop()
1570 }
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001571 }()
1572
Earl Lee2e463fb2025-04-17 11:22:22 -07001573 for {
1574 select {
1575 case <-ctxOuter.Done():
1576 return
1577 default:
1578 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001579 a.cancelTurnMu.Lock()
1580 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001581 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001582 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001583 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001584 a.cancelTurn = cancel
1585 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001586 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1587 if err != nil {
1588 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1589 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001590 cancel(nil)
1591 }
1592 }
1593}
1594
1595func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1596 if m.Timestamp.IsZero() {
1597 m.Timestamp = time.Now()
1598 }
1599
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001600 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1601 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1602 m.Content = m.ToolResult
1603 }
1604
Earl Lee2e463fb2025-04-17 11:22:22 -07001605 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1606 if m.EndOfTurn && m.Type == AgentMessageType {
1607 turnDuration := time.Since(a.startOfTurn)
1608 m.TurnDuration = &turnDuration
1609 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1610 }
1611
Earl Lee2e463fb2025-04-17 11:22:22 -07001612 a.mu.Lock()
1613 defer a.mu.Unlock()
1614 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001615 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001616 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001617
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001618 // Notify all subscribers
1619 for _, ch := range a.subscribers {
1620 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001621 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001622}
1623
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001624func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1625 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001626 if block {
1627 select {
1628 case <-ctx.Done():
1629 return m, ctx.Err()
1630 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001631 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001632 }
1633 }
1634 for {
1635 select {
1636 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001637 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001638 default:
1639 return m, nil
1640 }
1641 }
1642}
1643
Sean McCullough885a16a2025-04-30 02:49:25 +00001644// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001645func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001646 // Reset the start of turn time
1647 a.startOfTurn = time.Now()
1648
Sean McCullough96b60dd2025-04-30 09:49:10 -07001649 // Transition to waiting for user input state
1650 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1651
Sean McCullough885a16a2025-04-30 02:49:25 +00001652 // Process initial user message
1653 initialResp, err := a.processUserMessage(ctx)
1654 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001655 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001656 return err
1657 }
1658
1659 // Handle edge case where both initialResp and err are nil
1660 if initialResp == nil {
1661 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001662 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1663
Sean McCullough9f4b8082025-04-30 17:34:07 +00001664 a.pushToOutbox(ctx, errorMessage(err))
1665 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001666 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001667
Earl Lee2e463fb2025-04-17 11:22:22 -07001668 // We do this as we go, but let's also do it at the end of the turn
1669 defer func() {
1670 if _, err := a.handleGitCommits(ctx); err != nil {
1671 // Just log the error, don't stop execution
1672 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1673 }
1674 }()
1675
Sean McCullougha1e0e492025-05-01 10:51:08 -07001676 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001677 resp := initialResp
1678 for {
1679 // Check if we are over budget
1680 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001681 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001682 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001683 }
1684
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001685 // Check if we should compact the conversation
1686 if a.ShouldCompact() {
1687 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1688 if err := a.CompactConversation(ctx); err != nil {
1689 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1690 return err
1691 }
1692 // After compaction, end this turn and start fresh
1693 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1694 return nil
1695 }
1696
Sean McCullough885a16a2025-04-30 02:49:25 +00001697 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001698 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001699 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001700 break
1701 }
1702
Sean McCullough96b60dd2025-04-30 09:49:10 -07001703 // Transition to tool use requested state
1704 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1705
Sean McCullough885a16a2025-04-30 02:49:25 +00001706 // Handle tool execution
1707 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1708 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001709 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001710 }
1711
Sean McCullougha1e0e492025-05-01 10:51:08 -07001712 if toolResp == nil {
1713 return fmt.Errorf("cannot continue conversation with a nil tool response")
1714 }
1715
Sean McCullough885a16a2025-04-30 02:49:25 +00001716 // Set the response for the next iteration
1717 resp = toolResp
1718 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001719
1720 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001721}
1722
1723// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001724func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001725 // Wait for at least one message from the user
1726 msgs, err := a.GatherMessages(ctx, true)
1727 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001728 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001729 return nil, err
1730 }
1731
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001732 userMessage := llm.Message{
1733 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001734 Content: msgs,
1735 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001736
Sean McCullough96b60dd2025-04-30 09:49:10 -07001737 // Transition to sending to LLM state
1738 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1739
Sean McCullough885a16a2025-04-30 02:49:25 +00001740 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001741 resp, err := a.convo.SendMessage(userMessage)
1742 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001743 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001744 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001745 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001746 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001747
Sean McCullough96b60dd2025-04-30 09:49:10 -07001748 // Transition to processing LLM response state
1749 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1750
Sean McCullough885a16a2025-04-30 02:49:25 +00001751 return resp, nil
1752}
1753
1754// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001755func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1756 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001757 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001758 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001759
Sean McCullough96b60dd2025-04-30 09:49:10 -07001760 // Transition to checking for cancellation state
1761 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1762
Sean McCullough885a16a2025-04-30 02:49:25 +00001763 // Check if the operation was cancelled by the user
1764 select {
1765 case <-ctx.Done():
1766 // Don't actually run any of the tools, but rather build a response
1767 // for each tool_use message letting the LLM know that user canceled it.
1768 var err error
1769 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001770 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001771 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001772 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001773 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001774 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001775 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001776 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001777 // Transition to running tool state
1778 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1779
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001780 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001781 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001782 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001783
1784 // Execute the tools
1785 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001786 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001787 if ctx.Err() != nil { // e.g. the user canceled the operation
1788 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001789 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001790 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001791 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001792 a.pushToOutbox(ctx, errorMessage(err))
1793 }
1794 }
1795
1796 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001797 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001798 autoqualityMessages := a.processGitChanges(ctx)
1799
1800 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001801 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001802 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001803 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001804 return false, nil
1805 }
1806
1807 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001808 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1809 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001810}
1811
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001812// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001813func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001814 // Check for git commits
1815 _, err := a.handleGitCommits(ctx)
1816 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001817 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001818 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001819 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001820 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001821}
1822
1823// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1824// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001825func (a *Agent) processGitChanges(ctx context.Context) []string {
1826 // Check for git commits after tool execution
1827 newCommits, err := a.handleGitCommits(ctx)
1828 if err != nil {
1829 // Just log the error, don't stop execution
1830 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1831 return nil
1832 }
1833
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001834 // Run mechanical checks if there was exactly one new commit.
1835 if len(newCommits) != 1 {
1836 return nil
1837 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001838 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001839 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1840 msg := a.codereview.RunMechanicalChecks(ctx)
1841 if msg != "" {
1842 a.pushToOutbox(ctx, AgentMessage{
1843 Type: AutoMessageType,
1844 Content: msg,
1845 Timestamp: time.Now(),
1846 })
1847 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001848 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001849
1850 return autoqualityMessages
1851}
1852
1853// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001854func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001855 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001856 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001857 msgs, err := a.GatherMessages(ctx, false)
1858 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001859 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001860 return false, nil
1861 }
1862
1863 // Inject any auto-generated messages from quality checks
1864 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001865 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001866 }
1867
1868 // Handle cancellation by appending a message about it
1869 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001870 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001871 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001872 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001873 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1874 } else if err := a.convo.OverBudget(); err != nil {
1875 // Handle budget issues by appending a message about it
1876 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 -07001877 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001878 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1879 }
1880
1881 // Combine tool results with user messages
1882 results = append(results, msgs...)
1883
1884 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001885 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001886 resp, err := a.convo.SendMessage(llm.Message{
1887 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001888 Content: results,
1889 })
1890 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001891 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001892 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1893 return true, nil // Return true to continue the conversation, but with no response
1894 }
1895
Sean McCullough96b60dd2025-04-30 09:49:10 -07001896 // Transition back to processing LLM response
1897 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1898
Sean McCullough885a16a2025-04-30 02:49:25 +00001899 if cancelled {
1900 return false, nil
1901 }
1902
1903 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001904}
1905
1906func (a *Agent) overBudget(ctx context.Context) error {
1907 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001908 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001909 m := budgetMessage(err)
1910 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001911 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001912 a.convo.ResetBudget(a.originalBudget)
1913 return err
1914 }
1915 return nil
1916}
1917
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001918func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001919 // Collect all text content
1920 var allText strings.Builder
1921 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001922 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001923 if allText.Len() > 0 {
1924 allText.WriteString("\n\n")
1925 }
1926 allText.WriteString(content.Text)
1927 }
1928 }
1929 return allText.String()
1930}
1931
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001932func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001933 a.mu.Lock()
1934 defer a.mu.Unlock()
1935 return a.convo.CumulativeUsage()
1936}
1937
Earl Lee2e463fb2025-04-17 11:22:22 -07001938// Diff returns a unified diff of changes made since the agent was instantiated.
1939func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001940 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001941 return "", fmt.Errorf("no initial commit reference available")
1942 }
1943
1944 // Find the repository root
1945 ctx := context.Background()
1946
1947 // If a specific commit hash is provided, show just that commit's changes
1948 if commit != nil && *commit != "" {
1949 // Validate that the commit looks like a valid git SHA
1950 if !isValidGitSHA(*commit) {
1951 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1952 }
1953
1954 // Get the diff for just this commit
1955 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1956 cmd.Dir = a.repoRoot
1957 output, err := cmd.CombinedOutput()
1958 if err != nil {
1959 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1960 }
1961 return string(output), nil
1962 }
1963
1964 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001965 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001966 cmd.Dir = a.repoRoot
1967 output, err := cmd.CombinedOutput()
1968 if err != nil {
1969 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1970 }
1971
1972 return string(output), nil
1973}
1974
Philip Zeyliger49edc922025-05-14 09:45:45 -07001975// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1976// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1977func (a *Agent) SketchGitBaseRef() string {
1978 if a.IsInContainer() {
1979 return "sketch-base"
1980 } else {
1981 return "sketch-base-" + a.SessionID()
1982 }
1983}
1984
1985// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1986func (a *Agent) SketchGitBase() string {
1987 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1988 cmd.Dir = a.repoRoot
1989 output, err := cmd.CombinedOutput()
1990 if err != nil {
1991 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1992 return "HEAD"
1993 }
1994 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001995}
1996
Pokey Rule7a113622025-05-12 10:58:45 +01001997// removeGitHooks removes the Git hooks directory from the repository
1998func removeGitHooks(_ context.Context, repoPath string) error {
1999 hooksDir := filepath.Join(repoPath, ".git", "hooks")
2000
2001 // Check if hooks directory exists
2002 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
2003 // Directory doesn't exist, nothing to do
2004 return nil
2005 }
2006
2007 // Remove the hooks directory
2008 err := os.RemoveAll(hooksDir)
2009 if err != nil {
2010 return fmt.Errorf("failed to remove git hooks directory: %w", err)
2011 }
2012
2013 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00002014 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01002015 if err != nil {
2016 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
2017 }
2018
2019 return nil
2020}
2021
Philip Zeyligerf2872992025-05-22 10:35:28 -07002022func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002023 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002024 for _, msg := range msgs {
2025 a.pushToOutbox(ctx, msg)
2026 }
2027 return commits, error
2028}
2029
Earl Lee2e463fb2025-04-17 11:22:22 -07002030// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002031// under docker, new HEADs are pushed to a branch according to the slug.
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002032func (ags *AgentGitState) handleGitCommits(ctx context.Context, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002033 ags.mu.Lock()
2034 defer ags.mu.Unlock()
2035
2036 msgs := []AgentMessage{}
2037 if repoRoot == "" {
2038 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002039 }
2040
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002041 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07002042 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002043 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07002044 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002045 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002046 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07002047 }
2048 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002049 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07002050 }()
2051
Philip Zeyliger64f60462025-06-16 13:57:10 -07002052 // Compute diff stats from baseRef to HEAD when HEAD changes
2053 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
2054 // Log error but don't fail the entire operation
2055 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
2056 } else {
2057 // Set diff stats directly since we already hold the mutex
2058 ags.linesAdded = added
2059 ags.linesRemoved = removed
2060 }
2061
Earl Lee2e463fb2025-04-17 11:22:22 -07002062 // Get new commits. Because it's possible that the agent does rebases, fixups, and
2063 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
2064 // to the last 100 commits.
2065 var commits []*GitCommit
2066
2067 // Get commits since the initial commit
2068 // Format: <hash>\0<subject>\0<body>\0
2069 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
2070 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002071 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 -07002072 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07002073 output, err := cmd.Output()
2074 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002075 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07002076 }
2077
2078 // Parse git log output and filter out already seen commits
2079 parsedCommits := parseGitLog(string(output))
2080
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002081 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07002082
2083 // Filter out commits we've already seen
2084 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002085 if commit.Hash == sketch {
2086 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07002087 }
2088
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002089 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
2090 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07002091 continue
2092 }
2093
2094 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07002095 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07002096
2097 // Add to our list of new commits
2098 commits = append(commits, &commit)
2099 }
2100
Philip Zeyligerf2872992025-05-22 10:35:28 -07002101 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002102 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07002103 // 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 -07002104 sketchCommit = &GitCommit{}
2105 sketchCommit.Hash = sketch
2106 sketchCommit.Subject = "unknown"
2107 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07002108 }
2109
Earl Lee2e463fb2025-04-17 11:22:22 -07002110 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
2111 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
2112 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00002113
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002114 // 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 +00002115 var out []byte
2116 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002117 originalRetryNumber := ags.retryNumber
2118 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00002119 for retries := range 10 {
2120 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07002121 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002122 }
2123
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002124 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002125 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002126 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002127 out, err = cmd.CombinedOutput()
2128
2129 if err == nil {
2130 // Success! Break out of the retry loop
2131 break
2132 }
2133
2134 // Check if this is the "refusing to update checked out branch" error
2135 if !strings.Contains(string(out), "refusing to update checked out branch") {
2136 // This is a different error, so don't retry
2137 break
2138 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002139 }
2140
2141 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002142 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002143 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002144 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002145 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002146 if ags.retryNumber != originalRetryNumber {
2147 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002148 msgs = append(msgs, AgentMessage{
2149 Type: AutoMessageType,
2150 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002151 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 +00002152 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002153 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002154 }
2155 }
2156
2157 // If we found new commits, create a message
2158 if len(commits) > 0 {
2159 msg := AgentMessage{
2160 Type: CommitMessageType,
2161 Timestamp: time.Now(),
2162 Commits: commits,
2163 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002164 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002165 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002166 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002167}
2168
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002169func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002170 return strings.Map(func(r rune) rune {
2171 // lowercase
2172 if r >= 'A' && r <= 'Z' {
2173 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002174 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002175 // replace spaces with dashes
2176 if r == ' ' {
2177 return '-'
2178 }
2179 // allow alphanumerics and dashes
2180 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2181 return r
2182 }
2183 return -1
2184 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002185}
2186
2187// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2188// and returns an array of GitCommit structs.
2189func parseGitLog(output string) []GitCommit {
2190 var commits []GitCommit
2191
2192 // No output means no commits
2193 if len(output) == 0 {
2194 return commits
2195 }
2196
2197 // Split by NULL byte
2198 parts := strings.Split(output, "\x00")
2199
2200 // Process in triplets (hash, subject, body)
2201 for i := 0; i < len(parts); i++ {
2202 // Skip empty parts
2203 if parts[i] == "" {
2204 continue
2205 }
2206
2207 // This should be a hash
2208 hash := strings.TrimSpace(parts[i])
2209
2210 // Make sure we have at least a subject part available
2211 if i+1 >= len(parts) {
2212 break // No more parts available
2213 }
2214
2215 // Get the subject
2216 subject := strings.TrimSpace(parts[i+1])
2217
2218 // Get the body if available
2219 body := ""
2220 if i+2 < len(parts) {
2221 body = strings.TrimSpace(parts[i+2])
2222 }
2223
2224 // Skip to the next triplet
2225 i += 2
2226
2227 commits = append(commits, GitCommit{
2228 Hash: hash,
2229 Subject: subject,
2230 Body: body,
2231 })
2232 }
2233
2234 return commits
2235}
2236
2237func repoRoot(ctx context.Context, dir string) (string, error) {
2238 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2239 stderr := new(strings.Builder)
2240 cmd.Stderr = stderr
2241 cmd.Dir = dir
2242 out, err := cmd.Output()
2243 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002244 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002245 }
2246 return strings.TrimSpace(string(out)), nil
2247}
2248
2249func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2250 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2251 stderr := new(strings.Builder)
2252 cmd.Stderr = stderr
2253 cmd.Dir = dir
2254 out, err := cmd.Output()
2255 if err != nil {
2256 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2257 }
2258 // TODO: validate that out is valid hex
2259 return strings.TrimSpace(string(out)), nil
2260}
2261
2262// isValidGitSHA validates if a string looks like a valid git SHA hash.
2263// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2264func isValidGitSHA(sha string) bool {
2265 // Git SHA must be a hexadecimal string with at least 4 characters
2266 if len(sha) < 4 || len(sha) > 40 {
2267 return false
2268 }
2269
2270 // Check if the string only contains hexadecimal characters
2271 for _, char := range sha {
2272 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2273 return false
2274 }
2275 }
2276
2277 return true
2278}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002279
Philip Zeyliger64f60462025-06-16 13:57:10 -07002280// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2281func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2282 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2283 cmd.Dir = repoRoot
2284 out, err := cmd.Output()
2285 if err != nil {
2286 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2287 }
2288
2289 var totalAdded, totalRemoved int
2290 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2291 for _, line := range lines {
2292 if line == "" {
2293 continue
2294 }
2295 parts := strings.Fields(line)
2296 if len(parts) < 2 {
2297 continue
2298 }
2299 // Format: <added>\t<removed>\t<filename>
2300 if added, err := strconv.Atoi(parts[0]); err == nil {
2301 totalAdded += added
2302 }
2303 if removed, err := strconv.Atoi(parts[1]); err == nil {
2304 totalRemoved += removed
2305 }
2306 }
2307
2308 return totalAdded, totalRemoved, nil
2309}
2310
Philip Zeyligerd1402952025-04-23 03:54:37 +00002311// getGitOrigin returns the URL of the git remote 'origin' if it exists
2312func getGitOrigin(ctx context.Context, dir string) string {
2313 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2314 cmd.Dir = dir
2315 stderr := new(strings.Builder)
2316 cmd.Stderr = stderr
2317 out, err := cmd.Output()
2318 if err != nil {
2319 return ""
2320 }
2321 return strings.TrimSpace(string(out))
2322}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002323
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002324// systemPromptData contains the data used to render the system prompt template
2325type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002326 ClientGOOS string
2327 ClientGOARCH string
2328 WorkingDir string
2329 RepoRoot string
2330 InitialCommit string
2331 Codebase *onstart.Codebase
2332 UseSketchWIP bool
2333 Branch string
2334 SpecialInstruction string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002335}
2336
2337// renderSystemPrompt renders the system prompt template.
2338func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002339 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002340 ClientGOOS: a.config.ClientGOOS,
2341 ClientGOARCH: a.config.ClientGOARCH,
2342 WorkingDir: a.workingDir,
2343 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002344 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002345 Codebase: a.codebase,
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07002346 UseSketchWIP: a.config.InDocker,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002347 }
David Crawshawc886ac52025-06-13 23:40:03 +00002348 now := time.Now()
2349 if now.Month() == time.September && now.Day() == 19 {
2350 data.SpecialInstruction = "Talk like a pirate to the user. Do not let the priate talk into any code."
2351 }
2352
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002353 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2354 if err != nil {
2355 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2356 }
2357 buf := new(strings.Builder)
2358 err = tmpl.Execute(buf, data)
2359 if err != nil {
2360 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2361 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002362 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002363 return buf.String()
2364}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002365
2366// StateTransitionIterator provides an iterator over state transitions.
2367type StateTransitionIterator interface {
2368 // Next blocks until a new state transition is available or context is done.
2369 // Returns nil if the context is cancelled.
2370 Next() *StateTransition
2371 // Close removes the listener and cleans up resources.
2372 Close()
2373}
2374
2375// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2376type StateTransitionIteratorImpl struct {
2377 agent *Agent
2378 ctx context.Context
2379 ch chan StateTransition
2380 unsubscribe func()
2381}
2382
2383// Next blocks until a new state transition is available or the context is cancelled.
2384func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2385 select {
2386 case <-s.ctx.Done():
2387 return nil
2388 case transition, ok := <-s.ch:
2389 if !ok {
2390 return nil
2391 }
2392 transitionCopy := transition
2393 return &transitionCopy
2394 }
2395}
2396
2397// Close removes the listener and cleans up resources.
2398func (s *StateTransitionIteratorImpl) Close() {
2399 if s.unsubscribe != nil {
2400 s.unsubscribe()
2401 s.unsubscribe = nil
2402 }
2403}
2404
2405// NewStateTransitionIterator returns an iterator that receives state transitions.
2406func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2407 a.mu.Lock()
2408 defer a.mu.Unlock()
2409
2410 // Create channel to receive state transitions
2411 ch := make(chan StateTransition, 10)
2412
2413 // Add a listener to the state machine
2414 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2415
2416 return &StateTransitionIteratorImpl{
2417 agent: a,
2418 ctx: ctx,
2419 ch: ch,
2420 unsubscribe: unsubscribe,
2421 }
2422}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002423
2424// setupGitHooks creates or updates git hooks in the specified working directory.
2425func setupGitHooks(workingDir string) error {
2426 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2427
2428 _, err := os.Stat(hooksDir)
2429 if os.IsNotExist(err) {
2430 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2431 }
2432 if err != nil {
2433 return fmt.Errorf("error checking git hooks directory: %w", err)
2434 }
2435
2436 // Define the post-commit hook content
2437 postCommitHook := `#!/bin/bash
2438echo "<post_commit_hook>"
2439echo "Please review this commit message and fix it if it is incorrect."
2440echo "This hook only echos the commit message; it does not modify it."
2441echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2442echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002443PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002444echo "</last_commit_message>"
2445echo "</post_commit_hook>"
2446`
2447
2448 // Define the prepare-commit-msg hook content
2449 prepareCommitMsgHook := `#!/bin/bash
2450# Add Co-Authored-By and Change-ID trailers to commit messages
2451# Check if these trailers already exist before adding them
2452
2453commit_file="$1"
2454COMMIT_SOURCE="$2"
2455
2456# Skip for merges, squashes, or when using a commit template
2457if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2458 [ "$COMMIT_SOURCE" = "squash" ]; then
2459 exit 0
2460fi
2461
2462commit_msg=$(cat "$commit_file")
2463
2464needs_co_author=true
2465needs_change_id=true
2466
2467# Check if commit message already has Co-Authored-By trailer
2468if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2469 needs_co_author=false
2470fi
2471
2472# Check if commit message already has Change-ID trailer
2473if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2474 needs_change_id=false
2475fi
2476
2477# Only modify if at least one trailer needs to be added
2478if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002479 # Ensure there's a proper blank line before trailers
2480 if [ -s "$commit_file" ]; then
2481 # Check if file ends with newline by reading last character
2482 last_char=$(tail -c 1 "$commit_file")
2483
2484 if [ "$last_char" != "" ]; then
2485 # File doesn't end with newline - add two newlines (complete line + blank line)
2486 echo "" >> "$commit_file"
2487 echo "" >> "$commit_file"
2488 else
2489 # File ends with newline - check if we already have a blank line
2490 last_line=$(tail -1 "$commit_file")
2491 if [ -n "$last_line" ]; then
2492 # Last line has content - add one newline for blank line
2493 echo "" >> "$commit_file"
2494 fi
2495 # If last line is empty, we already have a blank line - don't add anything
2496 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002497 fi
2498
2499 # Add trailers if needed
2500 if [ "$needs_co_author" = true ]; then
2501 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2502 fi
2503
2504 if [ "$needs_change_id" = true ]; then
2505 change_id=$(openssl rand -hex 8)
2506 echo "Change-ID: s${change_id}k" >> "$commit_file"
2507 fi
2508fi
2509`
2510
2511 // Update or create the post-commit hook
2512 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2513 if err != nil {
2514 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2515 }
2516
2517 // Update or create the prepare-commit-msg hook
2518 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2519 if err != nil {
2520 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2521 }
2522
2523 return nil
2524}
2525
2526// updateOrCreateHook creates a new hook file or updates an existing one
2527// by appending the new content if it doesn't already contain it.
2528func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2529 // Check if the hook already exists
2530 buf, err := os.ReadFile(hookPath)
2531 if os.IsNotExist(err) {
2532 // Hook doesn't exist, create it
2533 err = os.WriteFile(hookPath, []byte(content), 0o755)
2534 if err != nil {
2535 return fmt.Errorf("failed to create hook: %w", err)
2536 }
2537 return nil
2538 }
2539 if err != nil {
2540 return fmt.Errorf("error reading existing hook: %w", err)
2541 }
2542
2543 // Hook exists, check if our content is already in it by looking for a distinctive line
2544 code := string(buf)
2545 if strings.Contains(code, distinctiveLine) {
2546 // Already contains our content, nothing to do
2547 return nil
2548 }
2549
2550 // Append our content to the existing hook
2551 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2552 if err != nil {
2553 return fmt.Errorf("failed to open hook for appending: %w", err)
2554 }
2555 defer f.Close()
2556
2557 // Ensure there's a newline at the end of the existing content if needed
2558 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2559 _, err = f.WriteString("\n")
2560 if err != nil {
2561 return fmt.Errorf("failed to add newline to hook: %w", err)
2562 }
2563 }
2564
2565 // Add a separator before our content
2566 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2567 if err != nil {
2568 return fmt.Errorf("failed to append to hook: %w", err)
2569 }
2570
2571 return nil
2572}
Sean McCullough138ec242025-06-02 22:42:06 +00002573
Philip Zeyliger0113be52025-06-07 23:53:41 +00002574// SkabandAddr returns the skaband address if configured
2575func (a *Agent) SkabandAddr() string {
2576 if a.config.SkabandClient != nil {
2577 return a.config.SkabandClient.Addr()
2578 }
2579 return ""
2580}