blob: 1416ab605c4ad9addcd597dbca5dab48c713fe3a [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
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700338 DebugJSON() ([]byte, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700339}
340
Philip Zeyligerf2872992025-05-22 10:35:28 -0700341// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700342// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700343// any time we notice we need to.
344type AgentGitState struct {
345 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700346 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700347 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000348 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700349 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700350 slug string // Human-readable session identifier
351 retryNumber int // Number to append when branch conflicts occur
Philip Zeyliger64f60462025-06-16 13:57:10 -0700352 linesAdded int // Lines added from sketch-base to HEAD
353 linesRemoved int // Lines removed from sketch-base to HEAD
Philip Zeyligerf2872992025-05-22 10:35:28 -0700354}
355
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700356func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700357 ags.mu.Lock()
358 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700359 if ags.slug != slug {
360 ags.retryNumber = 0
361 }
362 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700363}
364
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700365func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700366 ags.mu.Lock()
367 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700368 return ags.slug
369}
370
371func (ags *AgentGitState) IncrementRetryNumber() {
372 ags.mu.Lock()
373 defer ags.mu.Unlock()
374 ags.retryNumber++
375}
376
Philip Zeyliger64f60462025-06-16 13:57:10 -0700377func (ags *AgentGitState) DiffStats() (int, int) {
378 ags.mu.Lock()
379 defer ags.mu.Unlock()
380 return ags.linesAdded, ags.linesRemoved
381}
382
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700383// HasSeenCommits returns true if any commits have been processed
384func (ags *AgentGitState) HasSeenCommits() bool {
385 ags.mu.Lock()
386 defer ags.mu.Unlock()
387 return len(ags.seenCommits) > 0
388}
389
390func (ags *AgentGitState) RetryNumber() int {
391 ags.mu.Lock()
392 defer ags.mu.Unlock()
393 return ags.retryNumber
394}
395
396func (ags *AgentGitState) BranchName(prefix string) string {
397 ags.mu.Lock()
398 defer ags.mu.Unlock()
399 return ags.branchNameLocked(prefix)
400}
401
402func (ags *AgentGitState) branchNameLocked(prefix string) string {
403 if ags.slug == "" {
404 return ""
405 }
406 if ags.retryNumber == 0 {
407 return prefix + ags.slug
408 }
409 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700410}
411
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000412func (ags *AgentGitState) Upstream() string {
413 ags.mu.Lock()
414 defer ags.mu.Unlock()
415 return ags.upstream
416}
417
Earl Lee2e463fb2025-04-17 11:22:22 -0700418type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700419 convo ConvoInterface
420 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700421 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700422 workingDir string
423 repoRoot string // workingDir may be a subdir of repoRoot
424 url string
425 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000426 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700427 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000428 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700429 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700430 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000431 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700432 // State machine to track agent state
433 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000434 // Outside information
435 outsideHostname string
436 outsideOS string
437 outsideWorkingDir string
Philip Zeyliger194bfa82025-06-24 06:03:06 -0700438 // MCP manager for handling MCP server connections
439 mcpManager *mcp.MCPManager
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000440 // Port monitor for tracking TCP ports
441 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700442
443 // Time when the current turn started (reset at the beginning of InnerLoop)
444 startOfTurn time.Time
445
446 // Inbox - for messages from the user to the agent.
447 // sent on by UserMessage
448 // . e.g. when user types into the chat textarea
449 // read from by GatherMessages
450 inbox chan string
451
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000452 // protects cancelTurn
453 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700454 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000455 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700456
457 // protects following
458 mu sync.Mutex
459
460 // Stores all messages for this agent
461 history []AgentMessage
462
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700463 // Iterators add themselves here when they're ready to be notified of new messages.
464 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700465
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000466 // Track outstanding LLM call IDs
467 outstandingLLMCalls map[string]struct{}
468
469 // Track outstanding tool calls by ID with their names
470 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700471}
472
banksean5ab8fb82025-07-09 12:34:55 -0700473// TokenContextWindow implements CodingAgent.
474func (a *Agent) TokenContextWindow() int {
475 return a.config.Service.TokenContextWindow()
476}
477
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700478// GetConvo returns the conversation interface for debugging purposes.
479func (a *Agent) GetConvo() ConvoInterface {
480 return a.convo
481}
482
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700483// NewIterator implements CodingAgent.
484func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
485 a.mu.Lock()
486 defer a.mu.Unlock()
487
488 return &MessageIteratorImpl{
489 agent: a,
490 ctx: ctx,
491 nextMessageIdx: nextMessageIdx,
492 ch: make(chan *AgentMessage, 100),
493 }
494}
495
496type MessageIteratorImpl struct {
497 agent *Agent
498 ctx context.Context
499 nextMessageIdx int
500 ch chan *AgentMessage
501 subscribed bool
502}
503
504func (m *MessageIteratorImpl) Close() {
505 m.agent.mu.Lock()
506 defer m.agent.mu.Unlock()
507 // Delete ourselves from the subscribers list
508 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
509 return x == m.ch
510 })
511 close(m.ch)
512}
513
514func (m *MessageIteratorImpl) Next() *AgentMessage {
515 // We avoid subscription at creation to let ourselves catch up to "current state"
516 // before subscribing.
517 if !m.subscribed {
518 m.agent.mu.Lock()
519 if m.nextMessageIdx < len(m.agent.history) {
520 msg := &m.agent.history[m.nextMessageIdx]
521 m.nextMessageIdx++
522 m.agent.mu.Unlock()
523 return msg
524 }
525 // The next message doesn't exist yet, so let's subscribe
526 m.agent.subscribers = append(m.agent.subscribers, m.ch)
527 m.subscribed = true
528 m.agent.mu.Unlock()
529 }
530
531 for {
532 select {
533 case <-m.ctx.Done():
534 m.agent.mu.Lock()
535 // Delete ourselves from the subscribers list
536 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
537 return x == m.ch
538 })
539 m.subscribed = false
540 m.agent.mu.Unlock()
541 return nil
542 case msg, ok := <-m.ch:
543 if !ok {
544 // Close may have been called
545 return nil
546 }
547 if msg.Idx == m.nextMessageIdx {
548 m.nextMessageIdx++
549 return msg
550 }
551 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
552 panic("out of order message")
553 }
554 }
555}
556
Sean McCulloughd9d45812025-04-30 16:53:41 -0700557// Assert that Agent satisfies the CodingAgent interface.
558var _ CodingAgent = &Agent{}
559
560// StateName implements CodingAgent.
561func (a *Agent) CurrentStateName() string {
562 if a.stateMachine == nil {
563 return ""
564 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000565 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700566}
567
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700568// CurrentTodoContent returns the current todo list data as JSON.
569// It returns an empty string if no todos exist.
570func (a *Agent) CurrentTodoContent() string {
571 todoPath := claudetool.TodoFilePath(a.config.SessionID)
572 content, err := os.ReadFile(todoPath)
573 if err != nil {
574 return ""
575 }
576 return string(content)
577}
578
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700579// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
580func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
581 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.
582
583IMPORTANT: 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.
584
585Please create a detailed summary that includes:
586
5871. **User's Request**: What did the user originally ask me to do? What was their goal?
588
5892. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
590
5913. **Key Technical Decisions**: What important technical choices were made during our work and why?
592
5934. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
594
5955. **Next Steps**: What still needs to be done to complete the user's request?
596
5976. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
598
599Focus 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.
600
601Reply with ONLY the summary content - no meta-commentary about creating the summary.`
602
603 userMessage := llm.UserStringMessage(msg)
604 // Use a subconversation with history to get the summary
605 // TODO: We don't have any tools here, so we should have enough tokens
606 // to capture a summary, but we may need to modify the history (e.g., remove
607 // TODO data) to save on some tokens.
608 convo := a.convo.SubConvoWithHistory()
609
610 // Modify the system prompt to provide context about the original task
611 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000612 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 -0700613
614Your 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.
615
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000616Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700617
618 resp, err := convo.SendMessage(userMessage)
619 if err != nil {
620 a.pushToOutbox(ctx, errorMessage(err))
621 return "", err
622 }
623 textContent := collectTextContent(resp)
624
625 // Restore original system prompt (though this subconvo will be discarded)
626 convo.SystemPrompt = originalSystemPrompt
627
628 return textContent, nil
629}
630
631// CompactConversation compacts the current conversation by generating a summary
632// and restarting the conversation with that summary as the initial context
633func (a *Agent) CompactConversation(ctx context.Context) error {
634 summary, err := a.generateConversationSummary(ctx)
635 if err != nil {
636 return fmt.Errorf("failed to generate conversation summary: %w", err)
637 }
638
639 a.mu.Lock()
640
641 // Get usage information before resetting conversation
642 lastUsage := a.convo.LastUsage()
643 contextWindow := a.config.Service.TokenContextWindow()
644 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
645
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000646 // Preserve cumulative usage across compaction
647 cumulativeUsage := a.convo.CumulativeUsage()
648
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700649 // Reset conversation state but keep all other state (git, working dir, etc.)
650 a.firstMessageIndex = len(a.history)
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000651 a.convo = a.initConvoWithUsage(&cumulativeUsage)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700652
653 a.mu.Unlock()
654
655 // Create informative compaction message with token details
656 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
657 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
658 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
659
660 a.pushToOutbox(ctx, AgentMessage{
661 Type: CompactMessageType,
662 Content: compactionMsg,
663 })
664
665 a.pushToOutbox(ctx, AgentMessage{
666 Type: UserMessageType,
667 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),
668 })
669 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)
670
671 return nil
672}
673
Earl Lee2e463fb2025-04-17 11:22:22 -0700674func (a *Agent) URL() string { return a.url }
675
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000676// GetPorts returns the cached list of open TCP ports.
677func (a *Agent) GetPorts() []portlist.Port {
678 if a.portMonitor == nil {
679 return nil
680 }
681 return a.portMonitor.GetPorts()
682}
683
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000684// BranchName returns the git branch name for the conversation.
685func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700686 return a.gitState.BranchName(a.config.BranchPrefix)
687}
688
689// Slug returns the slug identifier for this conversation.
690func (a *Agent) Slug() string {
691 return a.gitState.Slug()
692}
693
694// IncrementRetryNumber increments the retry number for branch naming conflicts
695func (a *Agent) IncrementRetryNumber() {
696 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000697}
698
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000699// OutstandingLLMCallCount returns the number of outstanding LLM calls.
700func (a *Agent) OutstandingLLMCallCount() int {
701 a.mu.Lock()
702 defer a.mu.Unlock()
703 return len(a.outstandingLLMCalls)
704}
705
706// OutstandingToolCalls returns the names of outstanding tool calls.
707func (a *Agent) OutstandingToolCalls() []string {
708 a.mu.Lock()
709 defer a.mu.Unlock()
710
711 tools := make([]string, 0, len(a.outstandingToolCalls))
712 for _, toolName := range a.outstandingToolCalls {
713 tools = append(tools, toolName)
714 }
715 return tools
716}
717
Earl Lee2e463fb2025-04-17 11:22:22 -0700718// OS returns the operating system of the client.
719func (a *Agent) OS() string {
720 return a.config.ClientGOOS
721}
722
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000723func (a *Agent) SessionID() string {
724 return a.config.SessionID
725}
726
philip.zeyliger8773e682025-06-11 21:36:21 -0700727// SSHConnectionString returns the SSH connection string for the container.
728func (a *Agent) SSHConnectionString() string {
729 return a.config.SSHConnectionString
730}
731
Philip Zeyliger18532b22025-04-23 21:11:46 +0000732// OutsideOS returns the operating system of the outside system.
733func (a *Agent) OutsideOS() string {
734 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000735}
736
Philip Zeyliger18532b22025-04-23 21:11:46 +0000737// OutsideHostname returns the hostname of the outside system.
738func (a *Agent) OutsideHostname() string {
739 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000740}
741
Philip Zeyliger18532b22025-04-23 21:11:46 +0000742// OutsideWorkingDir returns the working directory on the outside system.
743func (a *Agent) OutsideWorkingDir() string {
744 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000745}
746
747// GitOrigin returns the URL of the git remote 'origin' if it exists.
748func (a *Agent) GitOrigin() string {
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +0000749 return a.config.OriginalGitOrigin
Philip Zeyligerd1402952025-04-23 03:54:37 +0000750}
751
bankseancad67b02025-06-27 21:57:05 +0000752// GitUsername returns the git user name from the agent config.
753func (a *Agent) GitUsername() string {
754 return a.config.GitUsername
755}
756
Philip Zeyliger64f60462025-06-16 13:57:10 -0700757// DiffStats returns the number of lines added and removed from sketch-base to HEAD
758func (a *Agent) DiffStats() (int, int) {
759 return a.gitState.DiffStats()
760}
761
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000762func (a *Agent) OpenBrowser(url string) {
763 if !a.IsInContainer() {
764 browser.Open(url)
765 return
766 }
767 // We're in Docker, need to send a request to the Git server
768 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700769 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000770 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700771 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000772 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700773 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000774 return
775 }
776 defer resp.Body.Close()
777 if resp.StatusCode == http.StatusOK {
778 return
779 }
780 body, _ := io.ReadAll(resp.Body)
781 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
782}
783
Sean McCullough96b60dd2025-04-30 09:49:10 -0700784// CurrentState returns the current state of the agent's state machine.
785func (a *Agent) CurrentState() State {
786 return a.stateMachine.CurrentState()
787}
788
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700789func (a *Agent) IsInContainer() bool {
790 return a.config.InDocker
791}
792
793func (a *Agent) FirstMessageIndex() int {
794 a.mu.Lock()
795 defer a.mu.Unlock()
796 return a.firstMessageIndex
797}
798
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700799// SetSlug sets a human-readable identifier for the conversation.
800func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700801 a.mu.Lock()
802 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700803
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700804 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000805 convo, ok := a.convo.(*conversation.Convo)
806 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700807 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000808 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700809}
810
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000811// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700812func (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 +0000813 // Track the tool call
814 a.mu.Lock()
815 a.outstandingToolCalls[id] = toolName
816 a.mu.Unlock()
817}
818
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700819// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
820// If there's only one element in the array and it's a text type, it returns that text directly.
821// It also processes nested ToolResult arrays recursively.
822func contentToString(contents []llm.Content) string {
823 if len(contents) == 0 {
824 return ""
825 }
826
827 // If there's only one element and it's a text type, return it directly
828 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
829 return contents[0].Text
830 }
831
832 // Otherwise, concatenate all text content
833 var result strings.Builder
834 for _, content := range contents {
835 if content.Type == llm.ContentTypeText {
836 result.WriteString(content.Text)
837 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
838 // Recursively process nested tool results
839 result.WriteString(contentToString(content.ToolResult))
840 }
841 }
842
843 return result.String()
844}
845
Earl Lee2e463fb2025-04-17 11:22:22 -0700846// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700847func (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 +0000848 // Remove the tool call from outstanding calls
849 a.mu.Lock()
850 delete(a.outstandingToolCalls, toolID)
851 a.mu.Unlock()
852
Earl Lee2e463fb2025-04-17 11:22:22 -0700853 m := AgentMessage{
854 Type: ToolUseMessageType,
855 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700856 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700857 ToolError: content.ToolError,
858 ToolName: toolName,
859 ToolInput: string(toolInput),
860 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700861 StartTime: content.ToolUseStartTime,
862 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700863 }
864
865 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700866 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
867 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700868 m.Elapsed = &elapsed
869 }
870
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700871 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700872 a.pushToOutbox(ctx, m)
873}
874
875// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700876func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000877 a.mu.Lock()
878 defer a.mu.Unlock()
879 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700880 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
881}
882
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700883// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700884// that need to be displayed (as well as tool calls that we send along when
885// they're done). (It would be reasonable to also mention tool calls when they're
886// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700887func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000888 // Remove the LLM call from outstanding calls
889 a.mu.Lock()
890 delete(a.outstandingLLMCalls, id)
891 a.mu.Unlock()
892
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700893 if resp == nil {
894 // LLM API call failed
895 m := AgentMessage{
896 Type: ErrorMessageType,
897 Content: "API call failed, type 'continue' to try again",
898 }
899 m.SetConvo(convo)
900 a.pushToOutbox(ctx, m)
901 return
902 }
903
Earl Lee2e463fb2025-04-17 11:22:22 -0700904 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700905 if convo.Parent == nil { // subconvos never end the turn
906 switch resp.StopReason {
907 case llm.StopReasonToolUse:
908 // Check whether any of the tool calls are for tools that should end the turn
909 ToolSearch:
910 for _, part := range resp.Content {
911 if part.Type != llm.ContentTypeToolUse {
912 continue
913 }
Sean McCullough021557a2025-05-05 23:20:53 +0000914 // Find the tool by name
915 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700916 if tool.Name == part.ToolName {
917 endOfTurn = tool.EndsTurn
918 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000919 }
920 }
Sean McCullough021557a2025-05-05 23:20:53 +0000921 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700922 default:
923 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000924 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700925 }
926 m := AgentMessage{
927 Type: AgentMessageType,
928 Content: collectTextContent(resp),
929 EndOfTurn: endOfTurn,
930 Usage: &resp.Usage,
931 StartTime: resp.StartTime,
932 EndTime: resp.EndTime,
933 }
934
935 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700936 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700937 var toolCalls []ToolCall
938 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700939 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700940 toolCalls = append(toolCalls, ToolCall{
941 Name: part.ToolName,
942 Input: string(part.ToolInput),
943 ToolCallId: part.ID,
944 })
945 }
946 }
947 m.ToolCalls = toolCalls
948 }
949
950 // Calculate the elapsed time if both start and end times are set
951 if resp.StartTime != nil && resp.EndTime != nil {
952 elapsed := resp.EndTime.Sub(*resp.StartTime)
953 m.Elapsed = &elapsed
954 }
955
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700956 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700957 a.pushToOutbox(ctx, m)
958}
959
960// WorkingDir implements CodingAgent.
961func (a *Agent) WorkingDir() string {
962 return a.workingDir
963}
964
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000965// RepoRoot returns the git repository root directory.
966func (a *Agent) RepoRoot() string {
967 return a.repoRoot
968}
969
Earl Lee2e463fb2025-04-17 11:22:22 -0700970// MessageCount implements CodingAgent.
971func (a *Agent) MessageCount() int {
972 a.mu.Lock()
973 defer a.mu.Unlock()
974 return len(a.history)
975}
976
977// Messages implements CodingAgent.
978func (a *Agent) Messages(start int, end int) []AgentMessage {
979 a.mu.Lock()
980 defer a.mu.Unlock()
981 return slices.Clone(a.history[start:end])
982}
983
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700984// ShouldCompact checks if the conversation should be compacted based on token usage
985func (a *Agent) ShouldCompact() bool {
986 // Get the threshold from environment variable, default to 0.94 (94%)
987 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
988 // and a little bit of buffer.)
989 thresholdRatio := 0.94
990 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
991 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
992 thresholdRatio = parsed
993 }
994 }
995
996 // Get the most recent usage to check current context size
997 lastUsage := a.convo.LastUsage()
998
999 if lastUsage.InputTokens == 0 {
1000 // No API calls made yet
1001 return false
1002 }
1003
1004 // Calculate the current context size from the last API call
1005 // This includes all tokens that were part of the input context:
1006 // - Input tokens (user messages, system prompt, conversation history)
1007 // - Cache read tokens (cached parts of the context)
1008 // - Cache creation tokens (new parts being cached)
1009 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
1010
1011 // Get the service's token context window
1012 service := a.config.Service
1013 contextWindow := service.TokenContextWindow()
1014
1015 // Calculate threshold
1016 threshold := uint64(float64(contextWindow) * thresholdRatio)
1017
1018 // Check if we've exceeded the threshold
1019 return currentContextSize >= threshold
1020}
1021
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001022func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -07001023 return a.originalBudget
1024}
1025
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001026// Upstream returns the upstream branch for git work
1027func (a *Agent) Upstream() string {
1028 return a.gitState.Upstream()
1029}
1030
Earl Lee2e463fb2025-04-17 11:22:22 -07001031// AgentConfig contains configuration for creating a new Agent.
1032type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001033 Context context.Context
1034 Service llm.Service
1035 Budget conversation.Budget
1036 GitUsername string
1037 GitEmail string
1038 SessionID string
1039 ClientGOOS string
1040 ClientGOARCH string
1041 InDocker bool
1042 OneShot bool
1043 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +00001044 // Outside information
1045 OutsideHostname string
1046 OutsideOS string
1047 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001048
1049 // Outtie's HTTP to, e.g., open a browser
1050 OutsideHTTP string
1051 // Outtie's Git server
1052 GitRemoteAddr string
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001053 // Original git origin URL from host repository, if any
1054 OriginalGitOrigin string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001055 // Upstream branch for git work
1056 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001057 // Commit to checkout from Outtie
1058 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001059 // Prefix for git branches created by sketch
1060 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001061 // LinkToGitHub enables GitHub branch linking in UI
1062 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001063 // SSH connection string for connecting to the container
1064 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001065 // Skaband client for session history (optional)
1066 SkabandClient *skabandclient.SkabandClient
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001067 // MCP server configurations
1068 MCPServers []string
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001069 // Timeout configuration for bash tool
1070 BashTimeouts *claudetool.Timeouts
Earl Lee2e463fb2025-04-17 11:22:22 -07001071}
1072
1073// NewAgent creates a new Agent.
1074// It is not usable until Init() is called.
1075func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001076 // Set default branch prefix if not specified
1077 if config.BranchPrefix == "" {
1078 config.BranchPrefix = "sketch/"
1079 }
1080
Earl Lee2e463fb2025-04-17 11:22:22 -07001081 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001082 config: config,
1083 ready: make(chan struct{}),
1084 inbox: make(chan string, 100),
1085 subscribers: make([]chan *AgentMessage, 0),
1086 startedAt: time.Now(),
1087 originalBudget: config.Budget,
1088 gitState: AgentGitState{
1089 seenCommits: make(map[string]bool),
1090 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001091 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001092 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001093 outsideHostname: config.OutsideHostname,
1094 outsideOS: config.OutsideOS,
1095 outsideWorkingDir: config.OutsideWorkingDir,
1096 outstandingLLMCalls: make(map[string]struct{}),
1097 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001098 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001099 workingDir: config.WorkingDir,
1100 outsideHTTP: config.OutsideHTTP,
Philip Zeyligerda623b52025-07-04 01:12:38 +00001101
1102 mcpManager: mcp.NewMCPManager(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001103 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001104
1105 // Initialize port monitor with 5-second interval
1106 agent.portMonitor = NewPortMonitor(agent, 5*time.Second)
1107
Earl Lee2e463fb2025-04-17 11:22:22 -07001108 return agent
1109}
1110
1111type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001112 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001113
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001114 InDocker bool
1115 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001116}
1117
1118func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001119 if a.convo != nil {
1120 return fmt.Errorf("Agent.Init: already initialized")
1121 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001122 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001123 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001124
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001125 // If a remote + commit was specified, clone it.
1126 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
1127 slog.InfoContext(ctx, "cloning git repo", "commit", a.config.Commit)
1128 // TODO: --reference-if-able instead?
1129 cmd := exec.CommandContext(ctx, "git", "clone", "--reference", "/git-ref", a.gitState.gitRemoteAddr, "/app")
1130 if out, err := cmd.CombinedOutput(); err != nil {
1131 return fmt.Errorf("failed to clone repository from %s: %s: %w", a.gitState.gitRemoteAddr, out, err)
1132 }
1133 }
1134
1135 if a.workingDir != "" {
1136 err := os.Chdir(a.workingDir)
1137 if err != nil {
1138 return fmt.Errorf("failed to change working directory to %s: %w", a.workingDir, err)
1139 }
1140 }
1141
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001142 if !ini.NoGit {
Philip Zeyligere1c8b7b2025-07-03 14:50:26 -07001143
1144 // Configure git user settings
1145 if a.config.GitEmail != "" {
1146 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.email", a.config.GitEmail)
1147 cmd.Dir = a.workingDir
1148 if out, err := cmd.CombinedOutput(); err != nil {
1149 return fmt.Errorf("git config --global user.email: %s: %v", out, err)
1150 }
1151 }
1152 if a.config.GitUsername != "" {
1153 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.name", a.config.GitUsername)
1154 cmd.Dir = a.workingDir
1155 if out, err := cmd.CombinedOutput(); err != nil {
1156 return fmt.Errorf("git config --global user.name: %s: %v", out, err)
1157 }
1158 }
1159 // Configure git http.postBuffer
1160 cmd := exec.CommandContext(ctx, "git", "config", "--global", "http.postBuffer", "524288000")
1161 cmd.Dir = a.workingDir
1162 if out, err := cmd.CombinedOutput(); err != nil {
1163 return fmt.Errorf("git config --global http.postBuffer: %s: %v", out, err)
1164 }
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001165 }
1166
Philip Zeyligerf2872992025-05-22 10:35:28 -07001167 // If a commit was specified, we fetch and reset to it.
1168 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001169 slog.InfoContext(ctx, "updating git repo", "commit", a.config.Commit)
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001170
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001171 cmd := exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
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 fetch: %s: %w", out, err)
1175 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001176 // The -B resets the branch if it already exists (or creates it if it doesn't)
1177 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001178 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001179 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1180 // Remove git hooks if they exist and retry
1181 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001182 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001183 if _, statErr := os.Stat(hookPath); statErr == nil {
1184 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1185 slog.String("error", err.Error()),
1186 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001187 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001188 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1189 }
1190
1191 // Retry the checkout operation
Philip Zeyliger1417b692025-06-12 11:07:04 -07001192 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001193 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001194 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001195 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 +01001196 }
1197 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001198 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001199 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001200 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001201 } else if a.IsInContainer() {
1202 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1203 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1204 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1205 cmd.Dir = a.workingDir
1206 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1207 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1208 }
1209 } else {
1210 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001211 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001212
1213 if ini.HostAddr != "" {
1214 a.url = "http://" + ini.HostAddr
1215 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001216
1217 if !ini.NoGit {
1218 repoRoot, err := repoRoot(ctx, a.workingDir)
1219 if err != nil {
1220 return fmt.Errorf("repoRoot: %w", err)
1221 }
1222 a.repoRoot = repoRoot
1223
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001224 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001225 if err := setupGitHooks(a.repoRoot); err != nil {
1226 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1227 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001228 }
1229
Philip Zeyliger49edc922025-05-14 09:45:45 -07001230 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1231 cmd.Dir = repoRoot
1232 if out, err := cmd.CombinedOutput(); err != nil {
1233 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1234 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001235
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001236 slog.Info("running codebase analysis")
1237 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1238 if err != nil {
1239 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001240 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001241 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001242
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001243 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001244 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001245 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001246 }
1247 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001248
Earl Lee2e463fb2025-04-17 11:22:22 -07001249 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001250 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001251 a.convo = a.initConvo()
1252 close(a.ready)
1253 return nil
1254}
1255
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001256//go:embed agent_system_prompt.txt
1257var agentSystemPrompt string
1258
Earl Lee2e463fb2025-04-17 11:22:22 -07001259// initConvo initializes the conversation.
1260// It must not be called until all agent fields are initialized,
1261// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001262func (a *Agent) initConvo() *conversation.Convo {
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001263 return a.initConvoWithUsage(nil)
1264}
1265
1266// initConvoWithUsage initializes the conversation with optional preserved usage.
1267func (a *Agent) initConvoWithUsage(usage *conversation.CumulativeUsage) *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001268 ctx := a.config.Context
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001269 convo := conversation.New(ctx, a.config.Service, usage)
Earl Lee2e463fb2025-04-17 11:22:22 -07001270 convo.PromptCaching = true
1271 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001272 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001273 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001274
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001275 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1276 bashPermissionCheck := func(command string) error {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001277 if a.gitState.Slug() != "" {
1278 return nil // branch is set up
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001279 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001280 willCommit, err := bashkit.WillRunGitCommit(command)
1281 if err != nil {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001282 return nil // fail open
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001283 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001284 if willCommit {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001285 return fmt.Errorf("you must use the set-slug tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001286 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001287 return nil
1288 }
1289
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001290 bashTool := &claudetool.BashTool{
1291 CheckPermission: bashPermissionCheck,
1292 EnableJITInstall: claudetool.EnableBashToolJITInstall,
1293 Timeouts: a.config.BashTimeouts,
1294 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001295
Earl Lee2e463fb2025-04-17 11:22:22 -07001296 // Register all tools with the conversation
1297 // When adding, removing, or modifying tools here, double-check that the termui tool display
1298 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001299
1300 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001301 _, supportsScreenshots := a.config.Service.(*ant.Service)
1302 var bTools []*llm.Tool
1303 var browserCleanup func()
1304
1305 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1306 // Add cleanup function to context cancel
1307 go func() {
1308 <-a.config.Context.Done()
1309 browserCleanup()
1310 }()
1311 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001312
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001313 convo.Tools = []*llm.Tool{
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001314 bashTool.Tool(), claudetool.Keyword, claudetool.Patch(a.patchCallback),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001315 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.setSlugTool(), a.commitMessageStyleTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001316 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001317 }
1318
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001319 // One-shot mode is non-interactive, multiple choice requires human response
1320 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001321 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001322 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001323
1324 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001325
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001326 // Add MCP tools if configured
1327 if len(a.config.MCPServers) > 0 {
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001328
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001329 slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers))
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001330 serverConfigs, parseErrors := mcp.ParseServerConfigs(ctx, a.config.MCPServers)
1331
1332 // Replace any headers with value _sketch_public_key_ and _sketch_session_id_ with those values.
1333 for i := range serverConfigs {
1334 if serverConfigs[i].Headers != nil {
1335 for key, value := range serverConfigs[i].Headers {
Philip Zeyligerf2814ea2025-06-30 10:16:50 -07001336 // Replace env placeholders. E.g., "env:FOO" becomes os.Getenv("FOO")
1337 if strings.HasPrefix(value, "env:") {
1338 serverConfigs[i].Headers[key] = os.Getenv(value[4:])
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001339 }
1340 }
1341 }
1342 }
1343 mcpConnections, mcpErrors := a.mcpManager.ConnectToServerConfigs(ctx, serverConfigs, 10*time.Second, parseErrors)
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001344
1345 if len(mcpErrors) > 0 {
1346 for _, err := range mcpErrors {
1347 slog.ErrorContext(ctx, "MCP connection error", "error", err)
1348 // Send agent message about MCP connection failures
1349 a.pushToOutbox(ctx, AgentMessage{
1350 Type: ErrorMessageType,
1351 Content: fmt.Sprintf("MCP server connection failed: %v", err),
1352 })
1353 }
1354 }
1355
1356 if len(mcpConnections) > 0 {
1357 // Add tools from all successful connections
1358 totalTools := 0
1359 for _, connection := range mcpConnections {
1360 convo.Tools = append(convo.Tools, connection.Tools...)
1361 totalTools += len(connection.Tools)
1362 // Log tools per server using structured data
1363 slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames)
1364 }
1365 slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools)
1366 } else {
1367 slog.InfoContext(ctx, "No MCP tools available after connection attempts")
1368 }
1369 }
1370
Earl Lee2e463fb2025-04-17 11:22:22 -07001371 convo.Listener = a
1372 return convo
1373}
1374
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001375var multipleChoiceTool = &llm.Tool{
1376 Name: "multiplechoice",
1377 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.",
1378 EndsTurn: true,
1379 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001380 "type": "object",
1381 "description": "The question and a list of answers you would expect the user to choose from.",
1382 "properties": {
1383 "question": {
1384 "type": "string",
1385 "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?'"
1386 },
1387 "responseOptions": {
1388 "type": "array",
1389 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1390 "items": {
1391 "type": "object",
1392 "properties": {
1393 "caption": {
1394 "type": "string",
1395 "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'"
1396 },
1397 "responseText": {
1398 "type": "string",
1399 "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'"
1400 }
1401 },
1402 "required": ["caption", "responseText"]
1403 }
1404 }
1405 },
1406 "required": ["question", "responseOptions"]
1407}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001408 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1409 // The Run logic for "multiplechoice" tool is a no-op on the server.
1410 // The UI will present a list of options for the user to select from,
1411 // and that's it as far as "executing" the tool_use goes.
1412 // When the user *does* select one of the presented options, that
1413 // responseText gets sent as a chat message on behalf of the user.
1414 return llm.TextContent("end your turn and wait for the user to respond"), nil
1415 },
Sean McCullough485afc62025-04-28 14:28:39 -07001416}
1417
1418type MultipleChoiceOption struct {
1419 Caption string `json:"caption"`
1420 ResponseText string `json:"responseText"`
1421}
1422
1423type MultipleChoiceParams struct {
1424 Question string `json:"question"`
1425 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1426}
1427
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001428// branchExists reports whether branchName exists, either locally or in well-known remotes.
1429func branchExists(dir, branchName string) bool {
1430 refs := []string{
1431 "refs/heads/",
1432 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001433 }
1434 for _, ref := range refs {
1435 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1436 cmd.Dir = dir
1437 if cmd.Run() == nil { // exit code 0 means branch exists
1438 return true
1439 }
1440 }
1441 return false
1442}
1443
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001444func (a *Agent) setSlugTool() *llm.Tool {
1445 return &llm.Tool{
1446 Name: "set-slug",
1447 Description: `Set a short slug as an identifier for this conversation.`,
Earl Lee2e463fb2025-04-17 11:22:22 -07001448 InputSchema: json.RawMessage(`{
1449 "type": "object",
1450 "properties": {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001451 "slug": {
Earl Lee2e463fb2025-04-17 11:22:22 -07001452 "type": "string",
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001453 "description": "A 2-3 word alphanumeric hyphenated slug, imperative tense"
Earl Lee2e463fb2025-04-17 11:22:22 -07001454 }
1455 },
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001456 "required": ["slug"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001457}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001458 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001459 var params struct {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001460 Slug string `json:"slug"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001461 }
1462 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001463 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001464 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001465 // Prevent slug changes if there have been git changes
1466 // This lets the agent change its mind about a good slug,
1467 // while ensuring that once a branch has been pushed, it remains stable.
1468 if s := a.Slug(); s != "" && s != params.Slug && a.gitState.HasSeenCommits() {
1469 return nil, fmt.Errorf("slug already set to %q", s)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001470 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001471 if params.Slug == "" {
1472 return nil, fmt.Errorf("slug parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001473 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001474 slug := cleanSlugName(params.Slug)
1475 if slug == "" {
1476 return nil, fmt.Errorf("slug parameter could not be converted to a valid slug")
1477 }
1478 a.SetSlug(slug)
1479 // TODO: do this by a call to outie, rather than semi-guessing from innie
1480 if branchExists(a.workingDir, a.BranchName()) {
1481 return nil, fmt.Errorf("slug %q already exists; please choose a different slug", slug)
1482 }
1483 return llm.TextContent("OK"), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001484 },
1485 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001486}
1487
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001488func (a *Agent) commitMessageStyleTool() *llm.Tool {
1489 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 +00001490 preCommit := &llm.Tool{
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001491 Name: "commit-message-style",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001492 Description: description,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001493 InputSchema: llm.EmptySchema(),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001494 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001495 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1496 if err != nil {
1497 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1498 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001499 return llm.TextContent(styleHint), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001500 },
1501 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001502 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001503}
1504
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001505// patchCallback is the agent's patch tool callback.
1506// It warms the codereview cache in the background.
1507func (a *Agent) patchCallback(input claudetool.PatchInput, result []llm.Content, err error) ([]llm.Content, error) {
1508 if a.codereview != nil {
1509 a.codereview.WarmTestCache(input.Path)
1510 }
1511 return result, err
1512}
1513
Earl Lee2e463fb2025-04-17 11:22:22 -07001514func (a *Agent) Ready() <-chan struct{} {
1515 return a.ready
1516}
1517
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001518// BranchPrefix returns the configured branch prefix
1519func (a *Agent) BranchPrefix() string {
1520 return a.config.BranchPrefix
1521}
1522
philip.zeyliger6d3de482025-06-10 19:38:14 -07001523// LinkToGitHub returns whether GitHub branch linking is enabled
1524func (a *Agent) LinkToGitHub() bool {
1525 return a.config.LinkToGitHub
1526}
1527
Earl Lee2e463fb2025-04-17 11:22:22 -07001528func (a *Agent) UserMessage(ctx context.Context, msg string) {
1529 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1530 a.inbox <- msg
1531}
1532
Earl Lee2e463fb2025-04-17 11:22:22 -07001533func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1534 return a.convo.CancelToolUse(toolUseID, cause)
1535}
1536
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001537func (a *Agent) CancelTurn(cause error) {
1538 a.cancelTurnMu.Lock()
1539 defer a.cancelTurnMu.Unlock()
1540 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001541 // Force state transition to cancelled state
1542 ctx := a.config.Context
1543 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001544 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001545 }
1546}
1547
1548func (a *Agent) Loop(ctxOuter context.Context) {
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001549 // Start port monitoring
1550 if a.portMonitor != nil && a.IsInContainer() {
1551 if err := a.portMonitor.Start(ctxOuter); err != nil {
1552 slog.WarnContext(ctxOuter, "Failed to start port monitor", "error", err)
1553 } else {
1554 slog.InfoContext(ctxOuter, "Port monitor started")
1555 }
1556 }
1557
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001558 // Set up cleanup when context is done
1559 defer func() {
1560 if a.mcpManager != nil {
1561 a.mcpManager.Close()
1562 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001563 if a.portMonitor != nil && a.IsInContainer() {
1564 a.portMonitor.Stop()
1565 }
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001566 }()
1567
Earl Lee2e463fb2025-04-17 11:22:22 -07001568 for {
1569 select {
1570 case <-ctxOuter.Done():
1571 return
1572 default:
1573 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001574 a.cancelTurnMu.Lock()
1575 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001576 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001577 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001578 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001579 a.cancelTurn = cancel
1580 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001581 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1582 if err != nil {
1583 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1584 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001585 cancel(nil)
1586 }
1587 }
1588}
1589
1590func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1591 if m.Timestamp.IsZero() {
1592 m.Timestamp = time.Now()
1593 }
1594
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001595 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1596 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1597 m.Content = m.ToolResult
1598 }
1599
Earl Lee2e463fb2025-04-17 11:22:22 -07001600 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1601 if m.EndOfTurn && m.Type == AgentMessageType {
1602 turnDuration := time.Since(a.startOfTurn)
1603 m.TurnDuration = &turnDuration
1604 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1605 }
1606
Earl Lee2e463fb2025-04-17 11:22:22 -07001607 a.mu.Lock()
1608 defer a.mu.Unlock()
1609 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001610 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001611 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001612
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001613 // Notify all subscribers
1614 for _, ch := range a.subscribers {
1615 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001616 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001617}
1618
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001619func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1620 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001621 if block {
1622 select {
1623 case <-ctx.Done():
1624 return m, ctx.Err()
1625 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001626 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001627 }
1628 }
1629 for {
1630 select {
1631 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001632 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001633 default:
1634 return m, nil
1635 }
1636 }
1637}
1638
Sean McCullough885a16a2025-04-30 02:49:25 +00001639// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001640func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001641 // Reset the start of turn time
1642 a.startOfTurn = time.Now()
1643
Sean McCullough96b60dd2025-04-30 09:49:10 -07001644 // Transition to waiting for user input state
1645 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1646
Sean McCullough885a16a2025-04-30 02:49:25 +00001647 // Process initial user message
1648 initialResp, err := a.processUserMessage(ctx)
1649 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001650 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001651 return err
1652 }
1653
1654 // Handle edge case where both initialResp and err are nil
1655 if initialResp == nil {
1656 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001657 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1658
Sean McCullough9f4b8082025-04-30 17:34:07 +00001659 a.pushToOutbox(ctx, errorMessage(err))
1660 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001661 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001662
Earl Lee2e463fb2025-04-17 11:22:22 -07001663 // We do this as we go, but let's also do it at the end of the turn
1664 defer func() {
1665 if _, err := a.handleGitCommits(ctx); err != nil {
1666 // Just log the error, don't stop execution
1667 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1668 }
1669 }()
1670
Sean McCullougha1e0e492025-05-01 10:51:08 -07001671 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001672 resp := initialResp
1673 for {
1674 // Check if we are over budget
1675 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001676 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001677 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001678 }
1679
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001680 // Check if we should compact the conversation
1681 if a.ShouldCompact() {
1682 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1683 if err := a.CompactConversation(ctx); err != nil {
1684 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1685 return err
1686 }
1687 // After compaction, end this turn and start fresh
1688 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1689 return nil
1690 }
1691
Sean McCullough885a16a2025-04-30 02:49:25 +00001692 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001693 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001694 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001695 break
1696 }
1697
Sean McCullough96b60dd2025-04-30 09:49:10 -07001698 // Transition to tool use requested state
1699 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1700
Sean McCullough885a16a2025-04-30 02:49:25 +00001701 // Handle tool execution
1702 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1703 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001704 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001705 }
1706
Sean McCullougha1e0e492025-05-01 10:51:08 -07001707 if toolResp == nil {
1708 return fmt.Errorf("cannot continue conversation with a nil tool response")
1709 }
1710
Sean McCullough885a16a2025-04-30 02:49:25 +00001711 // Set the response for the next iteration
1712 resp = toolResp
1713 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001714
1715 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001716}
1717
1718// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001719func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001720 // Wait for at least one message from the user
1721 msgs, err := a.GatherMessages(ctx, true)
1722 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001723 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001724 return nil, err
1725 }
1726
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001727 userMessage := llm.Message{
1728 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001729 Content: msgs,
1730 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001731
Sean McCullough96b60dd2025-04-30 09:49:10 -07001732 // Transition to sending to LLM state
1733 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1734
Sean McCullough885a16a2025-04-30 02:49:25 +00001735 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001736 resp, err := a.convo.SendMessage(userMessage)
1737 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001738 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001739 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001740 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001741 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001742
Sean McCullough96b60dd2025-04-30 09:49:10 -07001743 // Transition to processing LLM response state
1744 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1745
Sean McCullough885a16a2025-04-30 02:49:25 +00001746 return resp, nil
1747}
1748
1749// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001750func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1751 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001752 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001753 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001754
Sean McCullough96b60dd2025-04-30 09:49:10 -07001755 // Transition to checking for cancellation state
1756 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1757
Sean McCullough885a16a2025-04-30 02:49:25 +00001758 // Check if the operation was cancelled by the user
1759 select {
1760 case <-ctx.Done():
1761 // Don't actually run any of the tools, but rather build a response
1762 // for each tool_use message letting the LLM know that user canceled it.
1763 var err error
1764 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001765 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001766 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001767 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001768 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001769 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001770 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001771 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001772 // Transition to running tool state
1773 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1774
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001775 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001776 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001777 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001778
1779 // Execute the tools
1780 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001781 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001782 if ctx.Err() != nil { // e.g. the user canceled the operation
1783 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001784 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001785 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001786 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001787 a.pushToOutbox(ctx, errorMessage(err))
1788 }
1789 }
1790
1791 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001792 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001793 autoqualityMessages := a.processGitChanges(ctx)
1794
1795 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001796 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001797 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001798 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001799 return false, nil
1800 }
1801
1802 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001803 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1804 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001805}
1806
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001807// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001808func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001809 // Check for git commits
1810 _, err := a.handleGitCommits(ctx)
1811 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001812 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001813 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001814 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001815 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001816}
1817
1818// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1819// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001820func (a *Agent) processGitChanges(ctx context.Context) []string {
1821 // Check for git commits after tool execution
1822 newCommits, err := a.handleGitCommits(ctx)
1823 if err != nil {
1824 // Just log the error, don't stop execution
1825 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1826 return nil
1827 }
1828
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001829 // Run mechanical checks if there was exactly one new commit.
1830 if len(newCommits) != 1 {
1831 return nil
1832 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001833 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001834 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1835 msg := a.codereview.RunMechanicalChecks(ctx)
1836 if msg != "" {
1837 a.pushToOutbox(ctx, AgentMessage{
1838 Type: AutoMessageType,
1839 Content: msg,
1840 Timestamp: time.Now(),
1841 })
1842 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001843 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001844
1845 return autoqualityMessages
1846}
1847
1848// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001849func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001850 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001851 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001852 msgs, err := a.GatherMessages(ctx, false)
1853 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001854 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001855 return false, nil
1856 }
1857
1858 // Inject any auto-generated messages from quality checks
1859 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001860 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001861 }
1862
1863 // Handle cancellation by appending a message about it
1864 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001865 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001866 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001867 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001868 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1869 } else if err := a.convo.OverBudget(); err != nil {
1870 // Handle budget issues by appending a message about it
1871 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 -07001872 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001873 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1874 }
1875
1876 // Combine tool results with user messages
1877 results = append(results, msgs...)
1878
1879 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001880 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001881 resp, err := a.convo.SendMessage(llm.Message{
1882 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001883 Content: results,
1884 })
1885 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001886 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001887 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1888 return true, nil // Return true to continue the conversation, but with no response
1889 }
1890
Sean McCullough96b60dd2025-04-30 09:49:10 -07001891 // Transition back to processing LLM response
1892 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1893
Sean McCullough885a16a2025-04-30 02:49:25 +00001894 if cancelled {
1895 return false, nil
1896 }
1897
1898 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001899}
1900
1901func (a *Agent) overBudget(ctx context.Context) error {
1902 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001903 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001904 m := budgetMessage(err)
1905 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001906 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001907 a.convo.ResetBudget(a.originalBudget)
1908 return err
1909 }
1910 return nil
1911}
1912
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001913func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001914 // Collect all text content
1915 var allText strings.Builder
1916 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001917 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001918 if allText.Len() > 0 {
1919 allText.WriteString("\n\n")
1920 }
1921 allText.WriteString(content.Text)
1922 }
1923 }
1924 return allText.String()
1925}
1926
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001927func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001928 a.mu.Lock()
1929 defer a.mu.Unlock()
1930 return a.convo.CumulativeUsage()
1931}
1932
Earl Lee2e463fb2025-04-17 11:22:22 -07001933// Diff returns a unified diff of changes made since the agent was instantiated.
1934func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001935 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001936 return "", fmt.Errorf("no initial commit reference available")
1937 }
1938
1939 // Find the repository root
1940 ctx := context.Background()
1941
1942 // If a specific commit hash is provided, show just that commit's changes
1943 if commit != nil && *commit != "" {
1944 // Validate that the commit looks like a valid git SHA
1945 if !isValidGitSHA(*commit) {
1946 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1947 }
1948
1949 // Get the diff for just this commit
1950 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1951 cmd.Dir = a.repoRoot
1952 output, err := cmd.CombinedOutput()
1953 if err != nil {
1954 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1955 }
1956 return string(output), nil
1957 }
1958
1959 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001960 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001961 cmd.Dir = a.repoRoot
1962 output, err := cmd.CombinedOutput()
1963 if err != nil {
1964 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1965 }
1966
1967 return string(output), nil
1968}
1969
Philip Zeyliger49edc922025-05-14 09:45:45 -07001970// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1971// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1972func (a *Agent) SketchGitBaseRef() string {
1973 if a.IsInContainer() {
1974 return "sketch-base"
1975 } else {
1976 return "sketch-base-" + a.SessionID()
1977 }
1978}
1979
1980// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1981func (a *Agent) SketchGitBase() string {
1982 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1983 cmd.Dir = a.repoRoot
1984 output, err := cmd.CombinedOutput()
1985 if err != nil {
1986 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1987 return "HEAD"
1988 }
1989 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001990}
1991
Pokey Rule7a113622025-05-12 10:58:45 +01001992// removeGitHooks removes the Git hooks directory from the repository
1993func removeGitHooks(_ context.Context, repoPath string) error {
1994 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1995
1996 // Check if hooks directory exists
1997 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1998 // Directory doesn't exist, nothing to do
1999 return nil
2000 }
2001
2002 // Remove the hooks directory
2003 err := os.RemoveAll(hooksDir)
2004 if err != nil {
2005 return fmt.Errorf("failed to remove git hooks directory: %w", err)
2006 }
2007
2008 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00002009 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01002010 if err != nil {
2011 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
2012 }
2013
2014 return nil
2015}
2016
Philip Zeyligerf2872992025-05-22 10:35:28 -07002017func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002018 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002019 for _, msg := range msgs {
2020 a.pushToOutbox(ctx, msg)
2021 }
2022 return commits, error
2023}
2024
Earl Lee2e463fb2025-04-17 11:22:22 -07002025// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002026// under docker, new HEADs are pushed to a branch according to the slug.
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002027func (ags *AgentGitState) handleGitCommits(ctx context.Context, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002028 ags.mu.Lock()
2029 defer ags.mu.Unlock()
2030
2031 msgs := []AgentMessage{}
2032 if repoRoot == "" {
2033 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002034 }
2035
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002036 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07002037 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002038 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07002039 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002040 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002041 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07002042 }
2043 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002044 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07002045 }()
2046
Philip Zeyliger64f60462025-06-16 13:57:10 -07002047 // Compute diff stats from baseRef to HEAD when HEAD changes
2048 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
2049 // Log error but don't fail the entire operation
2050 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
2051 } else {
2052 // Set diff stats directly since we already hold the mutex
2053 ags.linesAdded = added
2054 ags.linesRemoved = removed
2055 }
2056
Earl Lee2e463fb2025-04-17 11:22:22 -07002057 // Get new commits. Because it's possible that the agent does rebases, fixups, and
2058 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
2059 // to the last 100 commits.
2060 var commits []*GitCommit
2061
2062 // Get commits since the initial commit
2063 // Format: <hash>\0<subject>\0<body>\0
2064 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
2065 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002066 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 -07002067 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07002068 output, err := cmd.Output()
2069 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002070 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07002071 }
2072
2073 // Parse git log output and filter out already seen commits
2074 parsedCommits := parseGitLog(string(output))
2075
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002076 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07002077
2078 // Filter out commits we've already seen
2079 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002080 if commit.Hash == sketch {
2081 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07002082 }
2083
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002084 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
2085 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07002086 continue
2087 }
2088
2089 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07002090 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07002091
2092 // Add to our list of new commits
2093 commits = append(commits, &commit)
2094 }
2095
Philip Zeyligerf2872992025-05-22 10:35:28 -07002096 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002097 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07002098 // 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 -07002099 sketchCommit = &GitCommit{}
2100 sketchCommit.Hash = sketch
2101 sketchCommit.Subject = "unknown"
2102 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07002103 }
2104
Earl Lee2e463fb2025-04-17 11:22:22 -07002105 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
2106 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
2107 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00002108
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002109 // 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 +00002110 var out []byte
2111 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002112 originalRetryNumber := ags.retryNumber
2113 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00002114 for retries := range 10 {
2115 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07002116 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002117 }
2118
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002119 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002120 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002121 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002122 out, err = cmd.CombinedOutput()
2123
2124 if err == nil {
2125 // Success! Break out of the retry loop
2126 break
2127 }
2128
2129 // Check if this is the "refusing to update checked out branch" error
2130 if !strings.Contains(string(out), "refusing to update checked out branch") {
2131 // This is a different error, so don't retry
2132 break
2133 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002134 }
2135
2136 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002137 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002138 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002139 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002140 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002141 if ags.retryNumber != originalRetryNumber {
2142 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002143 msgs = append(msgs, AgentMessage{
2144 Type: AutoMessageType,
2145 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002146 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 +00002147 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002148 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002149 }
2150 }
2151
2152 // If we found new commits, create a message
2153 if len(commits) > 0 {
2154 msg := AgentMessage{
2155 Type: CommitMessageType,
2156 Timestamp: time.Now(),
2157 Commits: commits,
2158 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002159 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002160 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002161 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002162}
2163
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002164func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002165 return strings.Map(func(r rune) rune {
2166 // lowercase
2167 if r >= 'A' && r <= 'Z' {
2168 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002169 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002170 // replace spaces with dashes
2171 if r == ' ' {
2172 return '-'
2173 }
2174 // allow alphanumerics and dashes
2175 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2176 return r
2177 }
2178 return -1
2179 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002180}
2181
2182// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2183// and returns an array of GitCommit structs.
2184func parseGitLog(output string) []GitCommit {
2185 var commits []GitCommit
2186
2187 // No output means no commits
2188 if len(output) == 0 {
2189 return commits
2190 }
2191
2192 // Split by NULL byte
2193 parts := strings.Split(output, "\x00")
2194
2195 // Process in triplets (hash, subject, body)
2196 for i := 0; i < len(parts); i++ {
2197 // Skip empty parts
2198 if parts[i] == "" {
2199 continue
2200 }
2201
2202 // This should be a hash
2203 hash := strings.TrimSpace(parts[i])
2204
2205 // Make sure we have at least a subject part available
2206 if i+1 >= len(parts) {
2207 break // No more parts available
2208 }
2209
2210 // Get the subject
2211 subject := strings.TrimSpace(parts[i+1])
2212
2213 // Get the body if available
2214 body := ""
2215 if i+2 < len(parts) {
2216 body = strings.TrimSpace(parts[i+2])
2217 }
2218
2219 // Skip to the next triplet
2220 i += 2
2221
2222 commits = append(commits, GitCommit{
2223 Hash: hash,
2224 Subject: subject,
2225 Body: body,
2226 })
2227 }
2228
2229 return commits
2230}
2231
2232func repoRoot(ctx context.Context, dir string) (string, error) {
2233 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2234 stderr := new(strings.Builder)
2235 cmd.Stderr = stderr
2236 cmd.Dir = dir
2237 out, err := cmd.Output()
2238 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002239 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002240 }
2241 return strings.TrimSpace(string(out)), nil
2242}
2243
2244func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2245 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2246 stderr := new(strings.Builder)
2247 cmd.Stderr = stderr
2248 cmd.Dir = dir
2249 out, err := cmd.Output()
2250 if err != nil {
2251 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2252 }
2253 // TODO: validate that out is valid hex
2254 return strings.TrimSpace(string(out)), nil
2255}
2256
2257// isValidGitSHA validates if a string looks like a valid git SHA hash.
2258// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2259func isValidGitSHA(sha string) bool {
2260 // Git SHA must be a hexadecimal string with at least 4 characters
2261 if len(sha) < 4 || len(sha) > 40 {
2262 return false
2263 }
2264
2265 // Check if the string only contains hexadecimal characters
2266 for _, char := range sha {
2267 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2268 return false
2269 }
2270 }
2271
2272 return true
2273}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002274
Philip Zeyliger64f60462025-06-16 13:57:10 -07002275// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2276func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2277 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2278 cmd.Dir = repoRoot
2279 out, err := cmd.Output()
2280 if err != nil {
2281 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2282 }
2283
2284 var totalAdded, totalRemoved int
2285 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2286 for _, line := range lines {
2287 if line == "" {
2288 continue
2289 }
2290 parts := strings.Fields(line)
2291 if len(parts) < 2 {
2292 continue
2293 }
2294 // Format: <added>\t<removed>\t<filename>
2295 if added, err := strconv.Atoi(parts[0]); err == nil {
2296 totalAdded += added
2297 }
2298 if removed, err := strconv.Atoi(parts[1]); err == nil {
2299 totalRemoved += removed
2300 }
2301 }
2302
2303 return totalAdded, totalRemoved, nil
2304}
2305
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002306// systemPromptData contains the data used to render the system prompt template
2307type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002308 ClientGOOS string
2309 ClientGOARCH string
2310 WorkingDir string
2311 RepoRoot string
2312 InitialCommit string
2313 Codebase *onstart.Codebase
2314 UseSketchWIP bool
2315 Branch string
2316 SpecialInstruction string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002317}
2318
2319// renderSystemPrompt renders the system prompt template.
2320func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002321 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002322 ClientGOOS: a.config.ClientGOOS,
2323 ClientGOARCH: a.config.ClientGOARCH,
2324 WorkingDir: a.workingDir,
2325 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002326 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002327 Codebase: a.codebase,
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07002328 UseSketchWIP: a.config.InDocker,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002329 }
David Crawshawc886ac52025-06-13 23:40:03 +00002330 now := time.Now()
2331 if now.Month() == time.September && now.Day() == 19 {
2332 data.SpecialInstruction = "Talk like a pirate to the user. Do not let the priate talk into any code."
2333 }
2334
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002335 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2336 if err != nil {
2337 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2338 }
2339 buf := new(strings.Builder)
2340 err = tmpl.Execute(buf, data)
2341 if err != nil {
2342 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2343 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002344 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002345 return buf.String()
2346}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002347
2348// StateTransitionIterator provides an iterator over state transitions.
2349type StateTransitionIterator interface {
2350 // Next blocks until a new state transition is available or context is done.
2351 // Returns nil if the context is cancelled.
2352 Next() *StateTransition
2353 // Close removes the listener and cleans up resources.
2354 Close()
2355}
2356
2357// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2358type StateTransitionIteratorImpl struct {
2359 agent *Agent
2360 ctx context.Context
2361 ch chan StateTransition
2362 unsubscribe func()
2363}
2364
2365// Next blocks until a new state transition is available or the context is cancelled.
2366func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2367 select {
2368 case <-s.ctx.Done():
2369 return nil
2370 case transition, ok := <-s.ch:
2371 if !ok {
2372 return nil
2373 }
2374 transitionCopy := transition
2375 return &transitionCopy
2376 }
2377}
2378
2379// Close removes the listener and cleans up resources.
2380func (s *StateTransitionIteratorImpl) Close() {
2381 if s.unsubscribe != nil {
2382 s.unsubscribe()
2383 s.unsubscribe = nil
2384 }
2385}
2386
2387// NewStateTransitionIterator returns an iterator that receives state transitions.
2388func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2389 a.mu.Lock()
2390 defer a.mu.Unlock()
2391
2392 // Create channel to receive state transitions
2393 ch := make(chan StateTransition, 10)
2394
2395 // Add a listener to the state machine
2396 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2397
2398 return &StateTransitionIteratorImpl{
2399 agent: a,
2400 ctx: ctx,
2401 ch: ch,
2402 unsubscribe: unsubscribe,
2403 }
2404}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002405
2406// setupGitHooks creates or updates git hooks in the specified working directory.
2407func setupGitHooks(workingDir string) error {
2408 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2409
2410 _, err := os.Stat(hooksDir)
2411 if os.IsNotExist(err) {
2412 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2413 }
2414 if err != nil {
2415 return fmt.Errorf("error checking git hooks directory: %w", err)
2416 }
2417
2418 // Define the post-commit hook content
2419 postCommitHook := `#!/bin/bash
2420echo "<post_commit_hook>"
2421echo "Please review this commit message and fix it if it is incorrect."
2422echo "This hook only echos the commit message; it does not modify it."
2423echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2424echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002425PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002426echo "</last_commit_message>"
2427echo "</post_commit_hook>"
2428`
2429
2430 // Define the prepare-commit-msg hook content
2431 prepareCommitMsgHook := `#!/bin/bash
2432# Add Co-Authored-By and Change-ID trailers to commit messages
2433# Check if these trailers already exist before adding them
2434
2435commit_file="$1"
2436COMMIT_SOURCE="$2"
2437
2438# Skip for merges, squashes, or when using a commit template
2439if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2440 [ "$COMMIT_SOURCE" = "squash" ]; then
2441 exit 0
2442fi
2443
2444commit_msg=$(cat "$commit_file")
2445
2446needs_co_author=true
2447needs_change_id=true
2448
2449# Check if commit message already has Co-Authored-By trailer
2450if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2451 needs_co_author=false
2452fi
2453
2454# Check if commit message already has Change-ID trailer
2455if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2456 needs_change_id=false
2457fi
2458
2459# Only modify if at least one trailer needs to be added
2460if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002461 # Ensure there's a proper blank line before trailers
2462 if [ -s "$commit_file" ]; then
2463 # Check if file ends with newline by reading last character
2464 last_char=$(tail -c 1 "$commit_file")
2465
2466 if [ "$last_char" != "" ]; then
2467 # File doesn't end with newline - add two newlines (complete line + blank line)
2468 echo "" >> "$commit_file"
2469 echo "" >> "$commit_file"
2470 else
2471 # File ends with newline - check if we already have a blank line
2472 last_line=$(tail -1 "$commit_file")
2473 if [ -n "$last_line" ]; then
2474 # Last line has content - add one newline for blank line
2475 echo "" >> "$commit_file"
2476 fi
2477 # If last line is empty, we already have a blank line - don't add anything
2478 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002479 fi
2480
2481 # Add trailers if needed
2482 if [ "$needs_co_author" = true ]; then
2483 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2484 fi
2485
2486 if [ "$needs_change_id" = true ]; then
2487 change_id=$(openssl rand -hex 8)
2488 echo "Change-ID: s${change_id}k" >> "$commit_file"
2489 fi
2490fi
2491`
2492
2493 // Update or create the post-commit hook
2494 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2495 if err != nil {
2496 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2497 }
2498
2499 // Update or create the prepare-commit-msg hook
2500 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2501 if err != nil {
2502 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2503 }
2504
2505 return nil
2506}
2507
2508// updateOrCreateHook creates a new hook file or updates an existing one
2509// by appending the new content if it doesn't already contain it.
2510func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2511 // Check if the hook already exists
2512 buf, err := os.ReadFile(hookPath)
2513 if os.IsNotExist(err) {
2514 // Hook doesn't exist, create it
2515 err = os.WriteFile(hookPath, []byte(content), 0o755)
2516 if err != nil {
2517 return fmt.Errorf("failed to create hook: %w", err)
2518 }
2519 return nil
2520 }
2521 if err != nil {
2522 return fmt.Errorf("error reading existing hook: %w", err)
2523 }
2524
2525 // Hook exists, check if our content is already in it by looking for a distinctive line
2526 code := string(buf)
2527 if strings.Contains(code, distinctiveLine) {
2528 // Already contains our content, nothing to do
2529 return nil
2530 }
2531
2532 // Append our content to the existing hook
2533 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2534 if err != nil {
2535 return fmt.Errorf("failed to open hook for appending: %w", err)
2536 }
2537 defer f.Close()
2538
2539 // Ensure there's a newline at the end of the existing content if needed
2540 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2541 _, err = f.WriteString("\n")
2542 if err != nil {
2543 return fmt.Errorf("failed to add newline to hook: %w", err)
2544 }
2545 }
2546
2547 // Add a separator before our content
2548 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2549 if err != nil {
2550 return fmt.Errorf("failed to append to hook: %w", err)
2551 }
2552
2553 return nil
2554}
Sean McCullough138ec242025-06-02 22:42:06 +00002555
Philip Zeyliger0113be52025-06-07 23:53:41 +00002556// SkabandAddr returns the skaband address if configured
2557func (a *Agent) SkabandAddr() string {
2558 if a.config.SkabandClient != nil {
2559 return a.config.SkabandClient.Addr()
2560 }
2561 return ""
2562}