blob: f01e601e8e558185fb63015a739a9b98ecd06e28 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package loop
2
3import (
4 "context"
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07005 _ "embed"
Earl Lee2e463fb2025-04-17 11:22:22 -07006 "encoding/json"
7 "fmt"
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +00008 "io"
Earl Lee2e463fb2025-04-17 11:22:22 -07009 "log/slog"
10 "net/http"
11 "os"
12 "os/exec"
Pokey Rule7a113622025-05-12 10:58:45 +010013 "path/filepath"
Earl Lee2e463fb2025-04-17 11:22:22 -070014 "runtime/debug"
15 "slices"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070016 "strconv"
Earl Lee2e463fb2025-04-17 11:22:22 -070017 "strings"
18 "sync"
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +000019 "text/template"
Earl Lee2e463fb2025-04-17 11:22:22 -070020 "time"
21
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000022 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070023 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000024 "sketch.dev/claudetool/bashkit"
Autoformatter4962f152025-05-06 17:24:20 +000025 "sketch.dev/claudetool/browse"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000026 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydera997be62025-05-07 22:52:46 +000027 "sketch.dev/claudetool/onstart"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070028 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070029 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070030 "sketch.dev/llm/conversation"
Philip Zeyliger194bfa82025-06-24 06:03:06 -070031 "sketch.dev/mcp"
Philip Zeyligerc17ffe32025-06-05 19:49:13 -070032 "sketch.dev/skabandclient"
Earl Lee2e463fb2025-04-17 11:22:22 -070033)
34
35const (
36 userCancelMessage = "user requested agent to stop handling responses"
37)
38
Philip Zeyligerb7c58752025-05-01 10:10:17 -070039type MessageIterator interface {
40 // Next blocks until the next message is available. It may
41 // return nil if the underlying iterator context is done.
42 Next() *AgentMessage
43 Close()
44}
45
Earl Lee2e463fb2025-04-17 11:22:22 -070046type CodingAgent interface {
47 // Init initializes an agent inside a docker container.
48 Init(AgentInit) error
49
50 // Ready returns a channel closed after Init successfully called.
51 Ready() <-chan struct{}
52
53 // URL reports the HTTP URL of this agent.
54 URL() string
55
56 // UserMessage enqueues a message to the agent and returns immediately.
57 UserMessage(ctx context.Context, msg string)
58
Philip Zeyligerb7c58752025-05-01 10:10:17 -070059 // Returns an iterator that finishes when the context is done and
60 // starts with the given message index.
61 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070062
Philip Zeyligereab12de2025-05-14 02:35:53 +000063 // Returns an iterator that notifies of state transitions until the context is done.
64 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
65
Earl Lee2e463fb2025-04-17 11:22:22 -070066 // Loop begins the agent loop returns only when ctx is cancelled.
67 Loop(ctx context.Context)
68
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000069 // BranchPrefix returns the configured branch prefix
70 BranchPrefix() string
71
philip.zeyliger6d3de482025-06-10 19:38:14 -070072 // LinkToGitHub returns whether GitHub branch linking is enabled
73 LinkToGitHub() bool
74
Sean McCulloughedc88dc2025-04-30 02:55:01 +000075 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070076
77 CancelToolUse(toolUseID string, cause error) error
78
79 // Returns a subset of the agent's message history.
80 Messages(start int, end int) []AgentMessage
81
82 // Returns the current number of messages in the history
83 MessageCount() int
84
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070085 TotalUsage() conversation.CumulativeUsage
86 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070087
Earl Lee2e463fb2025-04-17 11:22:22 -070088 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000089 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070090
91 // Diff returns a unified diff of changes made since the agent was instantiated.
92 // If commit is non-nil, it shows the diff for just that specific commit.
93 Diff(commit *string) (string, error)
94
Philip Zeyliger49edc922025-05-14 09:45:45 -070095 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
96 // starts out as the commit where sketch started, but a user can move it if need
97 // be, for example in the case of a rebase. It is stored as a git tag.
98 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070099
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000100 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
101 // (Typically, this is "sketch-base")
102 SketchGitBaseRef() string
103
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700104 // Slug returns the slug identifier for this session.
105 Slug() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700106
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000107 // BranchName returns the git branch name for the conversation.
108 BranchName() string
109
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700110 // IncrementRetryNumber increments the retry number for branch naming conflicts.
111 IncrementRetryNumber()
112
Earl Lee2e463fb2025-04-17 11:22:22 -0700113 // OS returns the operating system of the client.
114 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000115
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000116 // SessionID returns the unique session identifier.
117 SessionID() string
118
philip.zeyliger8773e682025-06-11 21:36:21 -0700119 // SSHConnectionString returns the SSH connection string for the container.
120 SSHConnectionString() string
121
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000122 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700123 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000124
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000125 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
126 OutstandingLLMCallCount() int
127
128 // OutstandingToolCalls returns the names of outstanding tool calls.
129 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000130 OutsideOS() string
131 OutsideHostname() string
132 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000133 GitOrigin() string
Philip Zeyliger64f60462025-06-16 13:57:10 -0700134
bankseancad67b02025-06-27 21:57:05 +0000135 // GitUsername returns the git user name from the agent config.
136 GitUsername() string
137
Philip Zeyliger64f60462025-06-16 13:57:10 -0700138 // DiffStats returns the number of lines added and removed from sketch-base to HEAD
139 DiffStats() (int, int)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000140 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
141 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700142
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700143 // IsInContainer returns true if the agent is running in a container
144 IsInContainer() bool
145 // FirstMessageIndex returns the index of the first message in the current conversation
146 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700147
148 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700149 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
150 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700151
152 // CompactConversation compacts the current conversation by generating a summary
153 // and restarting the conversation with that summary as the initial context
154 CompactConversation(ctx context.Context) error
Sean McCullough138ec242025-06-02 22:42:06 +0000155 // GetPortMonitor returns the port monitor instance for accessing port events
156 GetPortMonitor() *PortMonitor
Philip Zeyliger0113be52025-06-07 23:53:41 +0000157 // SkabandAddr returns the skaband address if configured
158 SkabandAddr() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700159}
160
161type CodingAgentMessageType string
162
163const (
164 UserMessageType CodingAgentMessageType = "user"
165 AgentMessageType CodingAgentMessageType = "agent"
166 ErrorMessageType CodingAgentMessageType = "error"
167 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
168 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700169 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
170 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
171 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700172
173 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
174)
175
176type AgentMessage struct {
177 Type CodingAgentMessageType `json:"type"`
178 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
179 EndOfTurn bool `json:"end_of_turn"`
180
181 Content string `json:"content"`
182 ToolName string `json:"tool_name,omitempty"`
183 ToolInput string `json:"input,omitempty"`
184 ToolResult string `json:"tool_result,omitempty"`
185 ToolError bool `json:"tool_error,omitempty"`
186 ToolCallId string `json:"tool_call_id,omitempty"`
187
188 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
189 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
190
Sean McCulloughd9f13372025-04-21 15:08:49 -0700191 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
192 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
193
Earl Lee2e463fb2025-04-17 11:22:22 -0700194 // Commits is a list of git commits for a commit message
195 Commits []*GitCommit `json:"commits,omitempty"`
196
197 Timestamp time.Time `json:"timestamp"`
198 ConversationID string `json:"conversation_id"`
199 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700200 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700201
202 // Message timing information
203 StartTime *time.Time `json:"start_time,omitempty"`
204 EndTime *time.Time `json:"end_time,omitempty"`
205 Elapsed *time.Duration `json:"elapsed,omitempty"`
206
207 // Turn duration - the time taken for a complete agent turn
208 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
209
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000210 // HideOutput indicates that this message should not be rendered in the UI.
211 // This is useful for subconversations that generate output that shouldn't be shown to the user.
212 HideOutput bool `json:"hide_output,omitempty"`
213
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700214 // TodoContent contains the agent's todo file content when it has changed
215 TodoContent *string `json:"todo_content,omitempty"`
216
Earl Lee2e463fb2025-04-17 11:22:22 -0700217 Idx int `json:"idx"`
218}
219
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000220// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700221func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700222 if convo == nil {
223 m.ConversationID = ""
224 m.ParentConversationID = nil
225 return
226 }
227 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000228 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700229 if convo.Parent != nil {
230 m.ParentConversationID = &convo.Parent.ID
231 }
232}
233
Earl Lee2e463fb2025-04-17 11:22:22 -0700234// GitCommit represents a single git commit for a commit message
235type GitCommit struct {
236 Hash string `json:"hash"` // Full commit hash
237 Subject string `json:"subject"` // Commit subject line
238 Body string `json:"body"` // Full commit message body
239 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
240}
241
242// ToolCall represents a single tool call within an agent message
243type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700244 Name string `json:"name"`
245 Input string `json:"input"`
246 ToolCallId string `json:"tool_call_id"`
247 ResultMessage *AgentMessage `json:"result_message,omitempty"`
248 Args string `json:"args,omitempty"`
249 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700250}
251
252func (a *AgentMessage) Attr() slog.Attr {
253 var attrs []any = []any{
254 slog.String("type", string(a.Type)),
255 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700256 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700257 if a.EndOfTurn {
258 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
259 }
260 if a.Content != "" {
261 attrs = append(attrs, slog.String("content", a.Content))
262 }
263 if a.ToolName != "" {
264 attrs = append(attrs, slog.String("tool_name", a.ToolName))
265 }
266 if a.ToolInput != "" {
267 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
268 }
269 if a.Elapsed != nil {
270 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
271 }
272 if a.TurnDuration != nil {
273 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
274 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700275 if len(a.ToolResult) > 0 {
276 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700277 }
278 if a.ToolError {
279 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
280 }
281 if len(a.ToolCalls) > 0 {
282 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
283 for i, tc := range a.ToolCalls {
284 toolCallAttrs = append(toolCallAttrs, slog.Group(
285 fmt.Sprintf("tool_call_%d", i),
286 slog.String("name", tc.Name),
287 slog.String("input", tc.Input),
288 ))
289 }
290 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
291 }
292 if a.ConversationID != "" {
293 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
294 }
295 if a.ParentConversationID != nil {
296 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
297 }
298 if a.Usage != nil && !a.Usage.IsZero() {
299 attrs = append(attrs, a.Usage.Attr())
300 }
301 // TODO: timestamp, convo ids, idx?
302 return slog.Group("agent_message", attrs...)
303}
304
305func errorMessage(err error) AgentMessage {
306 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
307 if os.Getenv(("DEBUG")) == "1" {
308 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
309 }
310
311 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
312}
313
314func budgetMessage(err error) AgentMessage {
315 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
316}
317
318// ConvoInterface defines the interface for conversation interactions
319type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700320 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700321 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700322 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700323 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700324 SendMessage(message llm.Message) (*llm.Response, error)
325 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700326 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000327 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700328 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700329 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700330 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700331}
332
Philip Zeyligerf2872992025-05-22 10:35:28 -0700333// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700334// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700335// any time we notice we need to.
336type AgentGitState struct {
337 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700338 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700339 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000340 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700341 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700342 slug string // Human-readable session identifier
343 retryNumber int // Number to append when branch conflicts occur
Philip Zeyliger64f60462025-06-16 13:57:10 -0700344 linesAdded int // Lines added from sketch-base to HEAD
345 linesRemoved int // Lines removed from sketch-base to HEAD
Philip Zeyligerf2872992025-05-22 10:35:28 -0700346}
347
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700348func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700349 ags.mu.Lock()
350 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700351 if ags.slug != slug {
352 ags.retryNumber = 0
353 }
354 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700355}
356
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700357func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700358 ags.mu.Lock()
359 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700360 return ags.slug
361}
362
363func (ags *AgentGitState) IncrementRetryNumber() {
364 ags.mu.Lock()
365 defer ags.mu.Unlock()
366 ags.retryNumber++
367}
368
Philip Zeyliger64f60462025-06-16 13:57:10 -0700369func (ags *AgentGitState) DiffStats() (int, int) {
370 ags.mu.Lock()
371 defer ags.mu.Unlock()
372 return ags.linesAdded, ags.linesRemoved
373}
374
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700375// HasSeenCommits returns true if any commits have been processed
376func (ags *AgentGitState) HasSeenCommits() bool {
377 ags.mu.Lock()
378 defer ags.mu.Unlock()
379 return len(ags.seenCommits) > 0
380}
381
382func (ags *AgentGitState) RetryNumber() int {
383 ags.mu.Lock()
384 defer ags.mu.Unlock()
385 return ags.retryNumber
386}
387
388func (ags *AgentGitState) BranchName(prefix string) string {
389 ags.mu.Lock()
390 defer ags.mu.Unlock()
391 return ags.branchNameLocked(prefix)
392}
393
394func (ags *AgentGitState) branchNameLocked(prefix string) string {
395 if ags.slug == "" {
396 return ""
397 }
398 if ags.retryNumber == 0 {
399 return prefix + ags.slug
400 }
401 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700402}
403
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000404func (ags *AgentGitState) Upstream() string {
405 ags.mu.Lock()
406 defer ags.mu.Unlock()
407 return ags.upstream
408}
409
Earl Lee2e463fb2025-04-17 11:22:22 -0700410type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700411 convo ConvoInterface
412 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700413 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700414 workingDir string
415 repoRoot string // workingDir may be a subdir of repoRoot
416 url string
417 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000418 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700419 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000420 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700421 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700422 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000423 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700424 // State machine to track agent state
425 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000426 // Outside information
427 outsideHostname string
428 outsideOS string
429 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000430 // URL of the git remote 'origin' if it exists
431 gitOrigin string
Philip Zeyliger194bfa82025-06-24 06:03:06 -0700432 // MCP manager for handling MCP server connections
433 mcpManager *mcp.MCPManager
Earl Lee2e463fb2025-04-17 11:22:22 -0700434
435 // Time when the current turn started (reset at the beginning of InnerLoop)
436 startOfTurn time.Time
437
438 // Inbox - for messages from the user to the agent.
439 // sent on by UserMessage
440 // . e.g. when user types into the chat textarea
441 // read from by GatherMessages
442 inbox chan string
443
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000444 // protects cancelTurn
445 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700446 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000447 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700448
449 // protects following
450 mu sync.Mutex
451
452 // Stores all messages for this agent
453 history []AgentMessage
454
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700455 // Iterators add themselves here when they're ready to be notified of new messages.
456 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700457
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000458 // Track outstanding LLM call IDs
459 outstandingLLMCalls map[string]struct{}
460
461 // Track outstanding tool calls by ID with their names
462 outstandingToolCalls map[string]string
Sean McCullough364f7412025-06-02 00:55:44 +0000463
464 // Port monitoring
465 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700466}
467
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700468// NewIterator implements CodingAgent.
469func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
470 a.mu.Lock()
471 defer a.mu.Unlock()
472
473 return &MessageIteratorImpl{
474 agent: a,
475 ctx: ctx,
476 nextMessageIdx: nextMessageIdx,
477 ch: make(chan *AgentMessage, 100),
478 }
479}
480
481type MessageIteratorImpl struct {
482 agent *Agent
483 ctx context.Context
484 nextMessageIdx int
485 ch chan *AgentMessage
486 subscribed bool
487}
488
489func (m *MessageIteratorImpl) Close() {
490 m.agent.mu.Lock()
491 defer m.agent.mu.Unlock()
492 // Delete ourselves from the subscribers list
493 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
494 return x == m.ch
495 })
496 close(m.ch)
497}
498
499func (m *MessageIteratorImpl) Next() *AgentMessage {
500 // We avoid subscription at creation to let ourselves catch up to "current state"
501 // before subscribing.
502 if !m.subscribed {
503 m.agent.mu.Lock()
504 if m.nextMessageIdx < len(m.agent.history) {
505 msg := &m.agent.history[m.nextMessageIdx]
506 m.nextMessageIdx++
507 m.agent.mu.Unlock()
508 return msg
509 }
510 // The next message doesn't exist yet, so let's subscribe
511 m.agent.subscribers = append(m.agent.subscribers, m.ch)
512 m.subscribed = true
513 m.agent.mu.Unlock()
514 }
515
516 for {
517 select {
518 case <-m.ctx.Done():
519 m.agent.mu.Lock()
520 // Delete ourselves from the subscribers list
521 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
522 return x == m.ch
523 })
524 m.subscribed = false
525 m.agent.mu.Unlock()
526 return nil
527 case msg, ok := <-m.ch:
528 if !ok {
529 // Close may have been called
530 return nil
531 }
532 if msg.Idx == m.nextMessageIdx {
533 m.nextMessageIdx++
534 return msg
535 }
536 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
537 panic("out of order message")
538 }
539 }
540}
541
Sean McCulloughd9d45812025-04-30 16:53:41 -0700542// Assert that Agent satisfies the CodingAgent interface.
543var _ CodingAgent = &Agent{}
544
545// StateName implements CodingAgent.
546func (a *Agent) CurrentStateName() string {
547 if a.stateMachine == nil {
548 return ""
549 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000550 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700551}
552
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700553// CurrentTodoContent returns the current todo list data as JSON.
554// It returns an empty string if no todos exist.
555func (a *Agent) CurrentTodoContent() string {
556 todoPath := claudetool.TodoFilePath(a.config.SessionID)
557 content, err := os.ReadFile(todoPath)
558 if err != nil {
559 return ""
560 }
561 return string(content)
562}
563
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700564// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
565func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
566 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.
567
568IMPORTANT: 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.
569
570Please create a detailed summary that includes:
571
5721. **User's Request**: What did the user originally ask me to do? What was their goal?
573
5742. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
575
5763. **Key Technical Decisions**: What important technical choices were made during our work and why?
577
5784. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
579
5805. **Next Steps**: What still needs to be done to complete the user's request?
581
5826. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
583
584Focus 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.
585
586Reply with ONLY the summary content - no meta-commentary about creating the summary.`
587
588 userMessage := llm.UserStringMessage(msg)
589 // Use a subconversation with history to get the summary
590 // TODO: We don't have any tools here, so we should have enough tokens
591 // to capture a summary, but we may need to modify the history (e.g., remove
592 // TODO data) to save on some tokens.
593 convo := a.convo.SubConvoWithHistory()
594
595 // Modify the system prompt to provide context about the original task
596 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000597 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 -0700598
599Your 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.
600
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000601Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700602
603 resp, err := convo.SendMessage(userMessage)
604 if err != nil {
605 a.pushToOutbox(ctx, errorMessage(err))
606 return "", err
607 }
608 textContent := collectTextContent(resp)
609
610 // Restore original system prompt (though this subconvo will be discarded)
611 convo.SystemPrompt = originalSystemPrompt
612
613 return textContent, nil
614}
615
616// CompactConversation compacts the current conversation by generating a summary
617// and restarting the conversation with that summary as the initial context
618func (a *Agent) CompactConversation(ctx context.Context) error {
619 summary, err := a.generateConversationSummary(ctx)
620 if err != nil {
621 return fmt.Errorf("failed to generate conversation summary: %w", err)
622 }
623
624 a.mu.Lock()
625
626 // Get usage information before resetting conversation
627 lastUsage := a.convo.LastUsage()
628 contextWindow := a.config.Service.TokenContextWindow()
629 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
630
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000631 // Preserve cumulative usage across compaction
632 cumulativeUsage := a.convo.CumulativeUsage()
633
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700634 // Reset conversation state but keep all other state (git, working dir, etc.)
635 a.firstMessageIndex = len(a.history)
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000636 a.convo = a.initConvoWithUsage(&cumulativeUsage)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700637
638 a.mu.Unlock()
639
640 // Create informative compaction message with token details
641 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
642 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
643 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
644
645 a.pushToOutbox(ctx, AgentMessage{
646 Type: CompactMessageType,
647 Content: compactionMsg,
648 })
649
650 a.pushToOutbox(ctx, AgentMessage{
651 Type: UserMessageType,
652 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),
653 })
654 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)
655
656 return nil
657}
658
Earl Lee2e463fb2025-04-17 11:22:22 -0700659func (a *Agent) URL() string { return a.url }
660
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000661// BranchName returns the git branch name for the conversation.
662func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700663 return a.gitState.BranchName(a.config.BranchPrefix)
664}
665
666// Slug returns the slug identifier for this conversation.
667func (a *Agent) Slug() string {
668 return a.gitState.Slug()
669}
670
671// IncrementRetryNumber increments the retry number for branch naming conflicts
672func (a *Agent) IncrementRetryNumber() {
673 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000674}
675
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000676// OutstandingLLMCallCount returns the number of outstanding LLM calls.
677func (a *Agent) OutstandingLLMCallCount() int {
678 a.mu.Lock()
679 defer a.mu.Unlock()
680 return len(a.outstandingLLMCalls)
681}
682
683// OutstandingToolCalls returns the names of outstanding tool calls.
684func (a *Agent) OutstandingToolCalls() []string {
685 a.mu.Lock()
686 defer a.mu.Unlock()
687
688 tools := make([]string, 0, len(a.outstandingToolCalls))
689 for _, toolName := range a.outstandingToolCalls {
690 tools = append(tools, toolName)
691 }
692 return tools
693}
694
Earl Lee2e463fb2025-04-17 11:22:22 -0700695// OS returns the operating system of the client.
696func (a *Agent) OS() string {
697 return a.config.ClientGOOS
698}
699
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000700func (a *Agent) SessionID() string {
701 return a.config.SessionID
702}
703
philip.zeyliger8773e682025-06-11 21:36:21 -0700704// SSHConnectionString returns the SSH connection string for the container.
705func (a *Agent) SSHConnectionString() string {
706 return a.config.SSHConnectionString
707}
708
Philip Zeyliger18532b22025-04-23 21:11:46 +0000709// OutsideOS returns the operating system of the outside system.
710func (a *Agent) OutsideOS() string {
711 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000712}
713
Philip Zeyliger18532b22025-04-23 21:11:46 +0000714// OutsideHostname returns the hostname of the outside system.
715func (a *Agent) OutsideHostname() string {
716 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000717}
718
Philip Zeyliger18532b22025-04-23 21:11:46 +0000719// OutsideWorkingDir returns the working directory on the outside system.
720func (a *Agent) OutsideWorkingDir() string {
721 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000722}
723
724// GitOrigin returns the URL of the git remote 'origin' if it exists.
725func (a *Agent) GitOrigin() string {
726 return a.gitOrigin
727}
728
bankseancad67b02025-06-27 21:57:05 +0000729// GitUsername returns the git user name from the agent config.
730func (a *Agent) GitUsername() string {
731 return a.config.GitUsername
732}
733
Philip Zeyliger64f60462025-06-16 13:57:10 -0700734// DiffStats returns the number of lines added and removed from sketch-base to HEAD
735func (a *Agent) DiffStats() (int, int) {
736 return a.gitState.DiffStats()
737}
738
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000739func (a *Agent) OpenBrowser(url string) {
740 if !a.IsInContainer() {
741 browser.Open(url)
742 return
743 }
744 // We're in Docker, need to send a request to the Git server
745 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700746 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000747 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700748 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000749 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700750 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000751 return
752 }
753 defer resp.Body.Close()
754 if resp.StatusCode == http.StatusOK {
755 return
756 }
757 body, _ := io.ReadAll(resp.Body)
758 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
759}
760
Sean McCullough96b60dd2025-04-30 09:49:10 -0700761// CurrentState returns the current state of the agent's state machine.
762func (a *Agent) CurrentState() State {
763 return a.stateMachine.CurrentState()
764}
765
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700766func (a *Agent) IsInContainer() bool {
767 return a.config.InDocker
768}
769
770func (a *Agent) FirstMessageIndex() int {
771 a.mu.Lock()
772 defer a.mu.Unlock()
773 return a.firstMessageIndex
774}
775
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700776// SetSlug sets a human-readable identifier for the conversation.
777func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700778 a.mu.Lock()
779 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700780
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700781 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000782 convo, ok := a.convo.(*conversation.Convo)
783 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700784 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000785 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700786}
787
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000788// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700789func (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 +0000790 // Track the tool call
791 a.mu.Lock()
792 a.outstandingToolCalls[id] = toolName
793 a.mu.Unlock()
794}
795
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700796// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
797// If there's only one element in the array and it's a text type, it returns that text directly.
798// It also processes nested ToolResult arrays recursively.
799func contentToString(contents []llm.Content) string {
800 if len(contents) == 0 {
801 return ""
802 }
803
804 // If there's only one element and it's a text type, return it directly
805 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
806 return contents[0].Text
807 }
808
809 // Otherwise, concatenate all text content
810 var result strings.Builder
811 for _, content := range contents {
812 if content.Type == llm.ContentTypeText {
813 result.WriteString(content.Text)
814 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
815 // Recursively process nested tool results
816 result.WriteString(contentToString(content.ToolResult))
817 }
818 }
819
820 return result.String()
821}
822
Earl Lee2e463fb2025-04-17 11:22:22 -0700823// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700824func (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 +0000825 // Remove the tool call from outstanding calls
826 a.mu.Lock()
827 delete(a.outstandingToolCalls, toolID)
828 a.mu.Unlock()
829
Earl Lee2e463fb2025-04-17 11:22:22 -0700830 m := AgentMessage{
831 Type: ToolUseMessageType,
832 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700833 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700834 ToolError: content.ToolError,
835 ToolName: toolName,
836 ToolInput: string(toolInput),
837 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700838 StartTime: content.ToolUseStartTime,
839 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700840 }
841
842 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700843 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
844 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700845 m.Elapsed = &elapsed
846 }
847
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700848 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700849 a.pushToOutbox(ctx, m)
850}
851
852// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700853func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000854 a.mu.Lock()
855 defer a.mu.Unlock()
856 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700857 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
858}
859
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700860// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700861// that need to be displayed (as well as tool calls that we send along when
862// they're done). (It would be reasonable to also mention tool calls when they're
863// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700864func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000865 // Remove the LLM call from outstanding calls
866 a.mu.Lock()
867 delete(a.outstandingLLMCalls, id)
868 a.mu.Unlock()
869
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700870 if resp == nil {
871 // LLM API call failed
872 m := AgentMessage{
873 Type: ErrorMessageType,
874 Content: "API call failed, type 'continue' to try again",
875 }
876 m.SetConvo(convo)
877 a.pushToOutbox(ctx, m)
878 return
879 }
880
Earl Lee2e463fb2025-04-17 11:22:22 -0700881 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700882 if convo.Parent == nil { // subconvos never end the turn
883 switch resp.StopReason {
884 case llm.StopReasonToolUse:
885 // Check whether any of the tool calls are for tools that should end the turn
886 ToolSearch:
887 for _, part := range resp.Content {
888 if part.Type != llm.ContentTypeToolUse {
889 continue
890 }
Sean McCullough021557a2025-05-05 23:20:53 +0000891 // Find the tool by name
892 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700893 if tool.Name == part.ToolName {
894 endOfTurn = tool.EndsTurn
895 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000896 }
897 }
Sean McCullough021557a2025-05-05 23:20:53 +0000898 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700899 default:
900 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000901 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700902 }
903 m := AgentMessage{
904 Type: AgentMessageType,
905 Content: collectTextContent(resp),
906 EndOfTurn: endOfTurn,
907 Usage: &resp.Usage,
908 StartTime: resp.StartTime,
909 EndTime: resp.EndTime,
910 }
911
912 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700913 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700914 var toolCalls []ToolCall
915 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700916 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700917 toolCalls = append(toolCalls, ToolCall{
918 Name: part.ToolName,
919 Input: string(part.ToolInput),
920 ToolCallId: part.ID,
921 })
922 }
923 }
924 m.ToolCalls = toolCalls
925 }
926
927 // Calculate the elapsed time if both start and end times are set
928 if resp.StartTime != nil && resp.EndTime != nil {
929 elapsed := resp.EndTime.Sub(*resp.StartTime)
930 m.Elapsed = &elapsed
931 }
932
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700933 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700934 a.pushToOutbox(ctx, m)
935}
936
937// WorkingDir implements CodingAgent.
938func (a *Agent) WorkingDir() string {
939 return a.workingDir
940}
941
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000942// RepoRoot returns the git repository root directory.
943func (a *Agent) RepoRoot() string {
944 return a.repoRoot
945}
946
Earl Lee2e463fb2025-04-17 11:22:22 -0700947// MessageCount implements CodingAgent.
948func (a *Agent) MessageCount() int {
949 a.mu.Lock()
950 defer a.mu.Unlock()
951 return len(a.history)
952}
953
954// Messages implements CodingAgent.
955func (a *Agent) Messages(start int, end int) []AgentMessage {
956 a.mu.Lock()
957 defer a.mu.Unlock()
958 return slices.Clone(a.history[start:end])
959}
960
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700961// ShouldCompact checks if the conversation should be compacted based on token usage
962func (a *Agent) ShouldCompact() bool {
963 // Get the threshold from environment variable, default to 0.94 (94%)
964 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
965 // and a little bit of buffer.)
966 thresholdRatio := 0.94
967 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
968 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
969 thresholdRatio = parsed
970 }
971 }
972
973 // Get the most recent usage to check current context size
974 lastUsage := a.convo.LastUsage()
975
976 if lastUsage.InputTokens == 0 {
977 // No API calls made yet
978 return false
979 }
980
981 // Calculate the current context size from the last API call
982 // This includes all tokens that were part of the input context:
983 // - Input tokens (user messages, system prompt, conversation history)
984 // - Cache read tokens (cached parts of the context)
985 // - Cache creation tokens (new parts being cached)
986 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
987
988 // Get the service's token context window
989 service := a.config.Service
990 contextWindow := service.TokenContextWindow()
991
992 // Calculate threshold
993 threshold := uint64(float64(contextWindow) * thresholdRatio)
994
995 // Check if we've exceeded the threshold
996 return currentContextSize >= threshold
997}
998
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700999func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -07001000 return a.originalBudget
1001}
1002
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001003// Upstream returns the upstream branch for git work
1004func (a *Agent) Upstream() string {
1005 return a.gitState.Upstream()
1006}
1007
Earl Lee2e463fb2025-04-17 11:22:22 -07001008// AgentConfig contains configuration for creating a new Agent.
1009type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001010 Context context.Context
1011 Service llm.Service
1012 Budget conversation.Budget
1013 GitUsername string
1014 GitEmail string
1015 SessionID string
1016 ClientGOOS string
1017 ClientGOARCH string
1018 InDocker bool
1019 OneShot bool
1020 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +00001021 // Outside information
1022 OutsideHostname string
1023 OutsideOS string
1024 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001025
1026 // Outtie's HTTP to, e.g., open a browser
1027 OutsideHTTP string
1028 // Outtie's Git server
1029 GitRemoteAddr string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001030 // Upstream branch for git work
1031 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001032 // Commit to checkout from Outtie
1033 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001034 // Prefix for git branches created by sketch
1035 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001036 // LinkToGitHub enables GitHub branch linking in UI
1037 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001038 // SSH connection string for connecting to the container
1039 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001040 // Skaband client for session history (optional)
1041 SkabandClient *skabandclient.SkabandClient
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001042 // MCP server configurations
1043 MCPServers []string
Earl Lee2e463fb2025-04-17 11:22:22 -07001044}
1045
1046// NewAgent creates a new Agent.
1047// It is not usable until Init() is called.
1048func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001049 // Set default branch prefix if not specified
1050 if config.BranchPrefix == "" {
1051 config.BranchPrefix = "sketch/"
1052 }
1053
Earl Lee2e463fb2025-04-17 11:22:22 -07001054 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001055 config: config,
1056 ready: make(chan struct{}),
1057 inbox: make(chan string, 100),
1058 subscribers: make([]chan *AgentMessage, 0),
1059 startedAt: time.Now(),
1060 originalBudget: config.Budget,
1061 gitState: AgentGitState{
1062 seenCommits: make(map[string]bool),
1063 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001064 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001065 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001066 outsideHostname: config.OutsideHostname,
1067 outsideOS: config.OutsideOS,
1068 outsideWorkingDir: config.OutsideWorkingDir,
1069 outstandingLLMCalls: make(map[string]struct{}),
1070 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001071 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001072 workingDir: config.WorkingDir,
1073 outsideHTTP: config.OutsideHTTP,
Sean McCullough364f7412025-06-02 00:55:44 +00001074 portMonitor: NewPortMonitor(),
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001075 mcpManager: mcp.NewMCPManager(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001076 }
1077 return agent
1078}
1079
1080type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001081 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001082
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001083 InDocker bool
1084 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001085}
1086
1087func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001088 if a.convo != nil {
1089 return fmt.Errorf("Agent.Init: already initialized")
1090 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001091 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001092 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001093
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001094 if !ini.NoGit {
1095 // Capture the original origin before we potentially replace it below
1096 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
Philip Zeyligere1c8b7b2025-07-03 14:50:26 -07001097
1098 // Configure git user settings
1099 if a.config.GitEmail != "" {
1100 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.email", a.config.GitEmail)
1101 cmd.Dir = a.workingDir
1102 if out, err := cmd.CombinedOutput(); err != nil {
1103 return fmt.Errorf("git config --global user.email: %s: %v", out, err)
1104 }
1105 }
1106 if a.config.GitUsername != "" {
1107 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.name", a.config.GitUsername)
1108 cmd.Dir = a.workingDir
1109 if out, err := cmd.CombinedOutput(); err != nil {
1110 return fmt.Errorf("git config --global user.name: %s: %v", out, err)
1111 }
1112 }
1113 // Configure git http.postBuffer
1114 cmd := exec.CommandContext(ctx, "git", "config", "--global", "http.postBuffer", "524288000")
1115 cmd.Dir = a.workingDir
1116 if out, err := cmd.CombinedOutput(); err != nil {
1117 return fmt.Errorf("git config --global http.postBuffer: %s: %v", out, err)
1118 }
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001119 }
1120
Philip Zeyliger222bf412025-06-04 16:42:58 +00001121 // If a remote git addr was specified, we configure the origin remote
Philip Zeyligerf2872992025-05-22 10:35:28 -07001122 if a.gitState.gitRemoteAddr != "" {
1123 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
Philip Zeyliger222bf412025-06-04 16:42:58 +00001124
1125 // Remove existing origin remote if it exists
1126 cmd := exec.CommandContext(ctx, "git", "remote", "remove", "origin")
Philip Zeyligerf2872992025-05-22 10:35:28 -07001127 cmd.Dir = a.workingDir
1128 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001129 // Ignore error if origin doesn't exist
1130 slog.DebugContext(ctx, "git remote remove origin (ignoring if not exists)", slog.String("output", string(out)))
Philip Zeyligerf2872992025-05-22 10:35:28 -07001131 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001132
1133 // Add the new remote as origin
1134 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", a.gitState.gitRemoteAddr)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001135 cmd.Dir = a.workingDir
1136 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001137 return fmt.Errorf("git remote add origin: %s: %v", out, err)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001138 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001139
Philip Zeyligerf2872992025-05-22 10:35:28 -07001140 }
1141
1142 // If a commit was specified, we fetch and reset to it.
1143 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001144 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1145
Earl Lee2e463fb2025-04-17 11:22:22 -07001146 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001147 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001148 if out, err := cmd.CombinedOutput(); err != nil {
1149 return fmt.Errorf("git stash: %s: %v", out, err)
1150 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001151 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001152 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001153 if out, err := cmd.CombinedOutput(); err != nil {
1154 return fmt.Errorf("git fetch: %s: %w", out, err)
1155 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001156 // The -B resets the branch if it already exists (or creates it if it doesn't)
1157 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001158 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001159 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1160 // Remove git hooks if they exist and retry
1161 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001162 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001163 if _, statErr := os.Stat(hookPath); statErr == nil {
1164 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1165 slog.String("error", err.Error()),
1166 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001167 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001168 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1169 }
1170
1171 // Retry the checkout operation
Philip Zeyliger1417b692025-06-12 11:07:04 -07001172 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001173 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001174 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001175 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 +01001176 }
1177 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001178 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001179 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001180 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001181 } else if a.IsInContainer() {
1182 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1183 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1184 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1185 cmd.Dir = a.workingDir
1186 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1187 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1188 }
1189 } else {
1190 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001191 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001192
1193 if ini.HostAddr != "" {
1194 a.url = "http://" + ini.HostAddr
1195 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001196
1197 if !ini.NoGit {
1198 repoRoot, err := repoRoot(ctx, a.workingDir)
1199 if err != nil {
1200 return fmt.Errorf("repoRoot: %w", err)
1201 }
1202 a.repoRoot = repoRoot
1203
Earl Lee2e463fb2025-04-17 11:22:22 -07001204 if err != nil {
1205 return fmt.Errorf("resolveRef: %w", err)
1206 }
Philip Zeyliger49edc922025-05-14 09:45:45 -07001207
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001208 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001209 if err := setupGitHooks(a.repoRoot); err != nil {
1210 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1211 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001212 }
1213
Philip Zeyliger49edc922025-05-14 09:45:45 -07001214 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1215 cmd.Dir = repoRoot
1216 if out, err := cmd.CombinedOutput(); err != nil {
1217 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1218 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001219
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001220 slog.Info("running codebase analysis")
1221 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1222 if err != nil {
1223 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001224 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001225 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001226
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001227 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001228 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001229 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001230 }
1231 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001232
Earl Lee2e463fb2025-04-17 11:22:22 -07001233 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001234 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001235 a.convo = a.initConvo()
1236 close(a.ready)
1237 return nil
1238}
1239
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001240//go:embed agent_system_prompt.txt
1241var agentSystemPrompt string
1242
Earl Lee2e463fb2025-04-17 11:22:22 -07001243// initConvo initializes the conversation.
1244// It must not be called until all agent fields are initialized,
1245// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001246func (a *Agent) initConvo() *conversation.Convo {
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001247 return a.initConvoWithUsage(nil)
1248}
1249
1250// initConvoWithUsage initializes the conversation with optional preserved usage.
1251func (a *Agent) initConvoWithUsage(usage *conversation.CumulativeUsage) *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001252 ctx := a.config.Context
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001253 convo := conversation.New(ctx, a.config.Service, usage)
Earl Lee2e463fb2025-04-17 11:22:22 -07001254 convo.PromptCaching = true
1255 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001256 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001257 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001258
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001259 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1260 bashPermissionCheck := func(command string) error {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001261 if a.gitState.Slug() != "" {
1262 return nil // branch is set up
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001263 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001264 willCommit, err := bashkit.WillRunGitCommit(command)
1265 if err != nil {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001266 return nil // fail open
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001267 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001268 if willCommit {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001269 return fmt.Errorf("you must use the set-slug tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001270 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001271 return nil
1272 }
1273
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001274 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001275
Earl Lee2e463fb2025-04-17 11:22:22 -07001276 // Register all tools with the conversation
1277 // When adding, removing, or modifying tools here, double-check that the termui tool display
1278 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001279
1280 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001281 _, supportsScreenshots := a.config.Service.(*ant.Service)
1282 var bTools []*llm.Tool
1283 var browserCleanup func()
1284
1285 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1286 // Add cleanup function to context cancel
1287 go func() {
1288 <-a.config.Context.Done()
1289 browserCleanup()
1290 }()
1291 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001292
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001293 convo.Tools = []*llm.Tool{
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001294 bashTool, claudetool.Keyword, claudetool.Patch(a.patchCallback),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001295 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.setSlugTool(), a.commitMessageStyleTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001296 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001297 }
1298
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001299 // One-shot mode is non-interactive, multiple choice requires human response
1300 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001301 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001302 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001303
1304 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001305
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001306 // Add MCP tools if configured
1307 if len(a.config.MCPServers) > 0 {
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001308
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001309 slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers))
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001310 serverConfigs, parseErrors := mcp.ParseServerConfigs(ctx, a.config.MCPServers)
1311
1312 // Replace any headers with value _sketch_public_key_ and _sketch_session_id_ with those values.
1313 for i := range serverConfigs {
1314 if serverConfigs[i].Headers != nil {
1315 for key, value := range serverConfigs[i].Headers {
Philip Zeyligerf2814ea2025-06-30 10:16:50 -07001316 // Replace env placeholders. E.g., "env:FOO" becomes os.Getenv("FOO")
1317 if strings.HasPrefix(value, "env:") {
1318 serverConfigs[i].Headers[key] = os.Getenv(value[4:])
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001319 }
1320 }
1321 }
1322 }
1323 mcpConnections, mcpErrors := a.mcpManager.ConnectToServerConfigs(ctx, serverConfigs, 10*time.Second, parseErrors)
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001324
1325 if len(mcpErrors) > 0 {
1326 for _, err := range mcpErrors {
1327 slog.ErrorContext(ctx, "MCP connection error", "error", err)
1328 // Send agent message about MCP connection failures
1329 a.pushToOutbox(ctx, AgentMessage{
1330 Type: ErrorMessageType,
1331 Content: fmt.Sprintf("MCP server connection failed: %v", err),
1332 })
1333 }
1334 }
1335
1336 if len(mcpConnections) > 0 {
1337 // Add tools from all successful connections
1338 totalTools := 0
1339 for _, connection := range mcpConnections {
1340 convo.Tools = append(convo.Tools, connection.Tools...)
1341 totalTools += len(connection.Tools)
1342 // Log tools per server using structured data
1343 slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames)
1344 }
1345 slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools)
1346 } else {
1347 slog.InfoContext(ctx, "No MCP tools available after connection attempts")
1348 }
1349 }
1350
Earl Lee2e463fb2025-04-17 11:22:22 -07001351 convo.Listener = a
1352 return convo
1353}
1354
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001355var multipleChoiceTool = &llm.Tool{
1356 Name: "multiplechoice",
1357 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.",
1358 EndsTurn: true,
1359 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001360 "type": "object",
1361 "description": "The question and a list of answers you would expect the user to choose from.",
1362 "properties": {
1363 "question": {
1364 "type": "string",
1365 "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?'"
1366 },
1367 "responseOptions": {
1368 "type": "array",
1369 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1370 "items": {
1371 "type": "object",
1372 "properties": {
1373 "caption": {
1374 "type": "string",
1375 "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'"
1376 },
1377 "responseText": {
1378 "type": "string",
1379 "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'"
1380 }
1381 },
1382 "required": ["caption", "responseText"]
1383 }
1384 }
1385 },
1386 "required": ["question", "responseOptions"]
1387}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001388 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1389 // The Run logic for "multiplechoice" tool is a no-op on the server.
1390 // The UI will present a list of options for the user to select from,
1391 // and that's it as far as "executing" the tool_use goes.
1392 // When the user *does* select one of the presented options, that
1393 // responseText gets sent as a chat message on behalf of the user.
1394 return llm.TextContent("end your turn and wait for the user to respond"), nil
1395 },
Sean McCullough485afc62025-04-28 14:28:39 -07001396}
1397
1398type MultipleChoiceOption struct {
1399 Caption string `json:"caption"`
1400 ResponseText string `json:"responseText"`
1401}
1402
1403type MultipleChoiceParams struct {
1404 Question string `json:"question"`
1405 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1406}
1407
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001408// branchExists reports whether branchName exists, either locally or in well-known remotes.
1409func branchExists(dir, branchName string) bool {
1410 refs := []string{
1411 "refs/heads/",
1412 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001413 }
1414 for _, ref := range refs {
1415 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1416 cmd.Dir = dir
1417 if cmd.Run() == nil { // exit code 0 means branch exists
1418 return true
1419 }
1420 }
1421 return false
1422}
1423
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001424func (a *Agent) setSlugTool() *llm.Tool {
1425 return &llm.Tool{
1426 Name: "set-slug",
1427 Description: `Set a short slug as an identifier for this conversation.`,
Earl Lee2e463fb2025-04-17 11:22:22 -07001428 InputSchema: json.RawMessage(`{
1429 "type": "object",
1430 "properties": {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001431 "slug": {
Earl Lee2e463fb2025-04-17 11:22:22 -07001432 "type": "string",
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001433 "description": "A 2-3 word alphanumeric hyphenated slug, imperative tense"
Earl Lee2e463fb2025-04-17 11:22:22 -07001434 }
1435 },
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001436 "required": ["slug"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001437}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001438 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001439 var params struct {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001440 Slug string `json:"slug"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001441 }
1442 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001443 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001444 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001445 // Prevent slug changes if there have been git changes
1446 // This lets the agent change its mind about a good slug,
1447 // while ensuring that once a branch has been pushed, it remains stable.
1448 if s := a.Slug(); s != "" && s != params.Slug && a.gitState.HasSeenCommits() {
1449 return nil, fmt.Errorf("slug already set to %q", s)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001450 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001451 if params.Slug == "" {
1452 return nil, fmt.Errorf("slug parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001453 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001454 slug := cleanSlugName(params.Slug)
1455 if slug == "" {
1456 return nil, fmt.Errorf("slug parameter could not be converted to a valid slug")
1457 }
1458 a.SetSlug(slug)
1459 // TODO: do this by a call to outie, rather than semi-guessing from innie
1460 if branchExists(a.workingDir, a.BranchName()) {
1461 return nil, fmt.Errorf("slug %q already exists; please choose a different slug", slug)
1462 }
1463 return llm.TextContent("OK"), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001464 },
1465 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001466}
1467
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001468func (a *Agent) commitMessageStyleTool() *llm.Tool {
1469 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 +00001470 preCommit := &llm.Tool{
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001471 Name: "commit-message-style",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001472 Description: description,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001473 InputSchema: llm.EmptySchema(),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001474 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001475 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1476 if err != nil {
1477 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1478 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001479 return llm.TextContent(styleHint), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001480 },
1481 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001482 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001483}
1484
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001485// patchCallback is the agent's patch tool callback.
1486// It warms the codereview cache in the background.
1487func (a *Agent) patchCallback(input claudetool.PatchInput, result []llm.Content, err error) ([]llm.Content, error) {
1488 if a.codereview != nil {
1489 a.codereview.WarmTestCache(input.Path)
1490 }
1491 return result, err
1492}
1493
Earl Lee2e463fb2025-04-17 11:22:22 -07001494func (a *Agent) Ready() <-chan struct{} {
1495 return a.ready
1496}
1497
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001498// BranchPrefix returns the configured branch prefix
1499func (a *Agent) BranchPrefix() string {
1500 return a.config.BranchPrefix
1501}
1502
philip.zeyliger6d3de482025-06-10 19:38:14 -07001503// LinkToGitHub returns whether GitHub branch linking is enabled
1504func (a *Agent) LinkToGitHub() bool {
1505 return a.config.LinkToGitHub
1506}
1507
Earl Lee2e463fb2025-04-17 11:22:22 -07001508func (a *Agent) UserMessage(ctx context.Context, msg string) {
1509 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1510 a.inbox <- msg
1511}
1512
Earl Lee2e463fb2025-04-17 11:22:22 -07001513func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1514 return a.convo.CancelToolUse(toolUseID, cause)
1515}
1516
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001517func (a *Agent) CancelTurn(cause error) {
1518 a.cancelTurnMu.Lock()
1519 defer a.cancelTurnMu.Unlock()
1520 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001521 // Force state transition to cancelled state
1522 ctx := a.config.Context
1523 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001524 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001525 }
1526}
1527
1528func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001529 // Start port monitoring when the agent loop begins
1530 // Only monitor ports when running in a container
1531 if a.IsInContainer() {
1532 a.portMonitor.Start(ctxOuter)
1533 }
1534
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001535 // Set up cleanup when context is done
1536 defer func() {
1537 if a.mcpManager != nil {
1538 a.mcpManager.Close()
1539 }
1540 }()
1541
Earl Lee2e463fb2025-04-17 11:22:22 -07001542 for {
1543 select {
1544 case <-ctxOuter.Done():
1545 return
1546 default:
1547 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001548 a.cancelTurnMu.Lock()
1549 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001550 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001551 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001552 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001553 a.cancelTurn = cancel
1554 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001555 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1556 if err != nil {
1557 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1558 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001559 cancel(nil)
1560 }
1561 }
1562}
1563
1564func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1565 if m.Timestamp.IsZero() {
1566 m.Timestamp = time.Now()
1567 }
1568
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001569 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1570 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1571 m.Content = m.ToolResult
1572 }
1573
Earl Lee2e463fb2025-04-17 11:22:22 -07001574 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1575 if m.EndOfTurn && m.Type == AgentMessageType {
1576 turnDuration := time.Since(a.startOfTurn)
1577 m.TurnDuration = &turnDuration
1578 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1579 }
1580
Earl Lee2e463fb2025-04-17 11:22:22 -07001581 a.mu.Lock()
1582 defer a.mu.Unlock()
1583 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001584 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001585 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001586
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001587 // Notify all subscribers
1588 for _, ch := range a.subscribers {
1589 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001590 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001591}
1592
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001593func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1594 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001595 if block {
1596 select {
1597 case <-ctx.Done():
1598 return m, ctx.Err()
1599 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001600 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001601 }
1602 }
1603 for {
1604 select {
1605 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001606 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001607 default:
1608 return m, nil
1609 }
1610 }
1611}
1612
Sean McCullough885a16a2025-04-30 02:49:25 +00001613// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001614func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001615 // Reset the start of turn time
1616 a.startOfTurn = time.Now()
1617
Sean McCullough96b60dd2025-04-30 09:49:10 -07001618 // Transition to waiting for user input state
1619 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1620
Sean McCullough885a16a2025-04-30 02:49:25 +00001621 // Process initial user message
1622 initialResp, err := a.processUserMessage(ctx)
1623 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001624 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001625 return err
1626 }
1627
1628 // Handle edge case where both initialResp and err are nil
1629 if initialResp == nil {
1630 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001631 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1632
Sean McCullough9f4b8082025-04-30 17:34:07 +00001633 a.pushToOutbox(ctx, errorMessage(err))
1634 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001635 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001636
Earl Lee2e463fb2025-04-17 11:22:22 -07001637 // We do this as we go, but let's also do it at the end of the turn
1638 defer func() {
1639 if _, err := a.handleGitCommits(ctx); err != nil {
1640 // Just log the error, don't stop execution
1641 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1642 }
1643 }()
1644
Sean McCullougha1e0e492025-05-01 10:51:08 -07001645 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001646 resp := initialResp
1647 for {
1648 // Check if we are over budget
1649 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001650 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001651 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001652 }
1653
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001654 // Check if we should compact the conversation
1655 if a.ShouldCompact() {
1656 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1657 if err := a.CompactConversation(ctx); err != nil {
1658 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1659 return err
1660 }
1661 // After compaction, end this turn and start fresh
1662 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1663 return nil
1664 }
1665
Sean McCullough885a16a2025-04-30 02:49:25 +00001666 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001667 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001668 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001669 break
1670 }
1671
Sean McCullough96b60dd2025-04-30 09:49:10 -07001672 // Transition to tool use requested state
1673 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1674
Sean McCullough885a16a2025-04-30 02:49:25 +00001675 // Handle tool execution
1676 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1677 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001678 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001679 }
1680
Sean McCullougha1e0e492025-05-01 10:51:08 -07001681 if toolResp == nil {
1682 return fmt.Errorf("cannot continue conversation with a nil tool response")
1683 }
1684
Sean McCullough885a16a2025-04-30 02:49:25 +00001685 // Set the response for the next iteration
1686 resp = toolResp
1687 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001688
1689 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001690}
1691
1692// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001693func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001694 // Wait for at least one message from the user
1695 msgs, err := a.GatherMessages(ctx, true)
1696 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001697 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001698 return nil, err
1699 }
1700
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001701 userMessage := llm.Message{
1702 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001703 Content: msgs,
1704 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001705
Sean McCullough96b60dd2025-04-30 09:49:10 -07001706 // Transition to sending to LLM state
1707 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1708
Sean McCullough885a16a2025-04-30 02:49:25 +00001709 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001710 resp, err := a.convo.SendMessage(userMessage)
1711 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001712 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001713 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001714 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001715 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001716
Sean McCullough96b60dd2025-04-30 09:49:10 -07001717 // Transition to processing LLM response state
1718 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1719
Sean McCullough885a16a2025-04-30 02:49:25 +00001720 return resp, nil
1721}
1722
1723// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001724func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1725 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001726 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001727 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001728
Sean McCullough96b60dd2025-04-30 09:49:10 -07001729 // Transition to checking for cancellation state
1730 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1731
Sean McCullough885a16a2025-04-30 02:49:25 +00001732 // Check if the operation was cancelled by the user
1733 select {
1734 case <-ctx.Done():
1735 // Don't actually run any of the tools, but rather build a response
1736 // for each tool_use message letting the LLM know that user canceled it.
1737 var err error
1738 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001739 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001740 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001741 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001742 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001743 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001744 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001745 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001746 // Transition to running tool state
1747 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1748
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001749 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001750 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001751 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001752
1753 // Execute the tools
1754 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001755 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001756 if ctx.Err() != nil { // e.g. the user canceled the operation
1757 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001758 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001759 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001760 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001761 a.pushToOutbox(ctx, errorMessage(err))
1762 }
1763 }
1764
1765 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001766 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001767 autoqualityMessages := a.processGitChanges(ctx)
1768
1769 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001770 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001771 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001772 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001773 return false, nil
1774 }
1775
1776 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001777 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1778 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001779}
1780
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001781// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001782func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001783 // Check for git commits
1784 _, err := a.handleGitCommits(ctx)
1785 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001786 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001787 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001788 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001789 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001790}
1791
1792// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1793// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001794func (a *Agent) processGitChanges(ctx context.Context) []string {
1795 // Check for git commits after tool execution
1796 newCommits, err := a.handleGitCommits(ctx)
1797 if err != nil {
1798 // Just log the error, don't stop execution
1799 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1800 return nil
1801 }
1802
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001803 // Run mechanical checks if there was exactly one new commit.
1804 if len(newCommits) != 1 {
1805 return nil
1806 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001807 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001808 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1809 msg := a.codereview.RunMechanicalChecks(ctx)
1810 if msg != "" {
1811 a.pushToOutbox(ctx, AgentMessage{
1812 Type: AutoMessageType,
1813 Content: msg,
1814 Timestamp: time.Now(),
1815 })
1816 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001817 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001818
1819 return autoqualityMessages
1820}
1821
1822// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001823func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001824 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001825 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001826 msgs, err := a.GatherMessages(ctx, false)
1827 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001828 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001829 return false, nil
1830 }
1831
1832 // Inject any auto-generated messages from quality checks
1833 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001834 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001835 }
1836
1837 // Handle cancellation by appending a message about it
1838 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001839 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001840 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001841 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001842 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1843 } else if err := a.convo.OverBudget(); err != nil {
1844 // Handle budget issues by appending a message about it
1845 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 -07001846 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001847 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1848 }
1849
1850 // Combine tool results with user messages
1851 results = append(results, msgs...)
1852
1853 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001854 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001855 resp, err := a.convo.SendMessage(llm.Message{
1856 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001857 Content: results,
1858 })
1859 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001860 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001861 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1862 return true, nil // Return true to continue the conversation, but with no response
1863 }
1864
Sean McCullough96b60dd2025-04-30 09:49:10 -07001865 // Transition back to processing LLM response
1866 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1867
Sean McCullough885a16a2025-04-30 02:49:25 +00001868 if cancelled {
1869 return false, nil
1870 }
1871
1872 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001873}
1874
1875func (a *Agent) overBudget(ctx context.Context) error {
1876 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001877 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001878 m := budgetMessage(err)
1879 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001880 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001881 a.convo.ResetBudget(a.originalBudget)
1882 return err
1883 }
1884 return nil
1885}
1886
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001887func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001888 // Collect all text content
1889 var allText strings.Builder
1890 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001891 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001892 if allText.Len() > 0 {
1893 allText.WriteString("\n\n")
1894 }
1895 allText.WriteString(content.Text)
1896 }
1897 }
1898 return allText.String()
1899}
1900
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001901func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001902 a.mu.Lock()
1903 defer a.mu.Unlock()
1904 return a.convo.CumulativeUsage()
1905}
1906
Earl Lee2e463fb2025-04-17 11:22:22 -07001907// Diff returns a unified diff of changes made since the agent was instantiated.
1908func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001909 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001910 return "", fmt.Errorf("no initial commit reference available")
1911 }
1912
1913 // Find the repository root
1914 ctx := context.Background()
1915
1916 // If a specific commit hash is provided, show just that commit's changes
1917 if commit != nil && *commit != "" {
1918 // Validate that the commit looks like a valid git SHA
1919 if !isValidGitSHA(*commit) {
1920 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1921 }
1922
1923 // Get the diff for just this commit
1924 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1925 cmd.Dir = a.repoRoot
1926 output, err := cmd.CombinedOutput()
1927 if err != nil {
1928 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1929 }
1930 return string(output), nil
1931 }
1932
1933 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001934 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001935 cmd.Dir = a.repoRoot
1936 output, err := cmd.CombinedOutput()
1937 if err != nil {
1938 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1939 }
1940
1941 return string(output), nil
1942}
1943
Philip Zeyliger49edc922025-05-14 09:45:45 -07001944// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1945// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1946func (a *Agent) SketchGitBaseRef() string {
1947 if a.IsInContainer() {
1948 return "sketch-base"
1949 } else {
1950 return "sketch-base-" + a.SessionID()
1951 }
1952}
1953
1954// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1955func (a *Agent) SketchGitBase() string {
1956 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1957 cmd.Dir = a.repoRoot
1958 output, err := cmd.CombinedOutput()
1959 if err != nil {
1960 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1961 return "HEAD"
1962 }
1963 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001964}
1965
Pokey Rule7a113622025-05-12 10:58:45 +01001966// removeGitHooks removes the Git hooks directory from the repository
1967func removeGitHooks(_ context.Context, repoPath string) error {
1968 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1969
1970 // Check if hooks directory exists
1971 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1972 // Directory doesn't exist, nothing to do
1973 return nil
1974 }
1975
1976 // Remove the hooks directory
1977 err := os.RemoveAll(hooksDir)
1978 if err != nil {
1979 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1980 }
1981
1982 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001983 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001984 if err != nil {
1985 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1986 }
1987
1988 return nil
1989}
1990
Philip Zeyligerf2872992025-05-22 10:35:28 -07001991func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001992 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001993 for _, msg := range msgs {
1994 a.pushToOutbox(ctx, msg)
1995 }
1996 return commits, error
1997}
1998
Earl Lee2e463fb2025-04-17 11:22:22 -07001999// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002000// under docker, new HEADs are pushed to a branch according to the slug.
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00002001func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002002 ags.mu.Lock()
2003 defer ags.mu.Unlock()
2004
2005 msgs := []AgentMessage{}
2006 if repoRoot == "" {
2007 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002008 }
2009
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002010 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07002011 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002012 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07002013 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002014 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002015 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07002016 }
2017 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002018 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07002019 }()
2020
Philip Zeyliger64f60462025-06-16 13:57:10 -07002021 // Compute diff stats from baseRef to HEAD when HEAD changes
2022 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
2023 // Log error but don't fail the entire operation
2024 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
2025 } else {
2026 // Set diff stats directly since we already hold the mutex
2027 ags.linesAdded = added
2028 ags.linesRemoved = removed
2029 }
2030
Earl Lee2e463fb2025-04-17 11:22:22 -07002031 // Get new commits. Because it's possible that the agent does rebases, fixups, and
2032 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
2033 // to the last 100 commits.
2034 var commits []*GitCommit
2035
2036 // Get commits since the initial commit
2037 // Format: <hash>\0<subject>\0<body>\0
2038 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
2039 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002040 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 -07002041 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07002042 output, err := cmd.Output()
2043 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002044 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07002045 }
2046
2047 // Parse git log output and filter out already seen commits
2048 parsedCommits := parseGitLog(string(output))
2049
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002050 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07002051
2052 // Filter out commits we've already seen
2053 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002054 if commit.Hash == sketch {
2055 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07002056 }
2057
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002058 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
2059 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07002060 continue
2061 }
2062
2063 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07002064 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07002065
2066 // Add to our list of new commits
2067 commits = append(commits, &commit)
2068 }
2069
Philip Zeyligerf2872992025-05-22 10:35:28 -07002070 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002071 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07002072 // 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 -07002073 sketchCommit = &GitCommit{}
2074 sketchCommit.Hash = sketch
2075 sketchCommit.Subject = "unknown"
2076 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07002077 }
2078
Earl Lee2e463fb2025-04-17 11:22:22 -07002079 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
2080 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
2081 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00002082
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002083 // 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 +00002084 var out []byte
2085 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002086 originalRetryNumber := ags.retryNumber
2087 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00002088 for retries := range 10 {
2089 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07002090 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002091 }
2092
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002093 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002094 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002095 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002096 out, err = cmd.CombinedOutput()
2097
2098 if err == nil {
2099 // Success! Break out of the retry loop
2100 break
2101 }
2102
2103 // Check if this is the "refusing to update checked out branch" error
2104 if !strings.Contains(string(out), "refusing to update checked out branch") {
2105 // This is a different error, so don't retry
2106 break
2107 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002108 }
2109
2110 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002111 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002112 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002113 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002114 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002115 if ags.retryNumber != originalRetryNumber {
2116 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002117 msgs = append(msgs, AgentMessage{
2118 Type: AutoMessageType,
2119 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002120 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 +00002121 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002122 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002123 }
2124 }
2125
2126 // If we found new commits, create a message
2127 if len(commits) > 0 {
2128 msg := AgentMessage{
2129 Type: CommitMessageType,
2130 Timestamp: time.Now(),
2131 Commits: commits,
2132 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002133 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002134 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002135 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002136}
2137
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002138func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002139 return strings.Map(func(r rune) rune {
2140 // lowercase
2141 if r >= 'A' && r <= 'Z' {
2142 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002143 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002144 // replace spaces with dashes
2145 if r == ' ' {
2146 return '-'
2147 }
2148 // allow alphanumerics and dashes
2149 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2150 return r
2151 }
2152 return -1
2153 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002154}
2155
2156// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2157// and returns an array of GitCommit structs.
2158func parseGitLog(output string) []GitCommit {
2159 var commits []GitCommit
2160
2161 // No output means no commits
2162 if len(output) == 0 {
2163 return commits
2164 }
2165
2166 // Split by NULL byte
2167 parts := strings.Split(output, "\x00")
2168
2169 // Process in triplets (hash, subject, body)
2170 for i := 0; i < len(parts); i++ {
2171 // Skip empty parts
2172 if parts[i] == "" {
2173 continue
2174 }
2175
2176 // This should be a hash
2177 hash := strings.TrimSpace(parts[i])
2178
2179 // Make sure we have at least a subject part available
2180 if i+1 >= len(parts) {
2181 break // No more parts available
2182 }
2183
2184 // Get the subject
2185 subject := strings.TrimSpace(parts[i+1])
2186
2187 // Get the body if available
2188 body := ""
2189 if i+2 < len(parts) {
2190 body = strings.TrimSpace(parts[i+2])
2191 }
2192
2193 // Skip to the next triplet
2194 i += 2
2195
2196 commits = append(commits, GitCommit{
2197 Hash: hash,
2198 Subject: subject,
2199 Body: body,
2200 })
2201 }
2202
2203 return commits
2204}
2205
2206func repoRoot(ctx context.Context, dir string) (string, error) {
2207 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2208 stderr := new(strings.Builder)
2209 cmd.Stderr = stderr
2210 cmd.Dir = dir
2211 out, err := cmd.Output()
2212 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002213 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002214 }
2215 return strings.TrimSpace(string(out)), nil
2216}
2217
2218func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2219 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2220 stderr := new(strings.Builder)
2221 cmd.Stderr = stderr
2222 cmd.Dir = dir
2223 out, err := cmd.Output()
2224 if err != nil {
2225 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2226 }
2227 // TODO: validate that out is valid hex
2228 return strings.TrimSpace(string(out)), nil
2229}
2230
2231// isValidGitSHA validates if a string looks like a valid git SHA hash.
2232// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2233func isValidGitSHA(sha string) bool {
2234 // Git SHA must be a hexadecimal string with at least 4 characters
2235 if len(sha) < 4 || len(sha) > 40 {
2236 return false
2237 }
2238
2239 // Check if the string only contains hexadecimal characters
2240 for _, char := range sha {
2241 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2242 return false
2243 }
2244 }
2245
2246 return true
2247}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002248
Philip Zeyliger64f60462025-06-16 13:57:10 -07002249// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2250func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2251 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2252 cmd.Dir = repoRoot
2253 out, err := cmd.Output()
2254 if err != nil {
2255 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2256 }
2257
2258 var totalAdded, totalRemoved int
2259 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2260 for _, line := range lines {
2261 if line == "" {
2262 continue
2263 }
2264 parts := strings.Fields(line)
2265 if len(parts) < 2 {
2266 continue
2267 }
2268 // Format: <added>\t<removed>\t<filename>
2269 if added, err := strconv.Atoi(parts[0]); err == nil {
2270 totalAdded += added
2271 }
2272 if removed, err := strconv.Atoi(parts[1]); err == nil {
2273 totalRemoved += removed
2274 }
2275 }
2276
2277 return totalAdded, totalRemoved, nil
2278}
2279
Philip Zeyligerd1402952025-04-23 03:54:37 +00002280// getGitOrigin returns the URL of the git remote 'origin' if it exists
2281func getGitOrigin(ctx context.Context, dir string) string {
2282 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2283 cmd.Dir = dir
2284 stderr := new(strings.Builder)
2285 cmd.Stderr = stderr
2286 out, err := cmd.Output()
2287 if err != nil {
2288 return ""
2289 }
2290 return strings.TrimSpace(string(out))
2291}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002292
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002293// systemPromptData contains the data used to render the system prompt template
2294type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002295 ClientGOOS string
2296 ClientGOARCH string
2297 WorkingDir string
2298 RepoRoot string
2299 InitialCommit string
2300 Codebase *onstart.Codebase
2301 UseSketchWIP bool
2302 Branch string
2303 SpecialInstruction string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002304}
2305
2306// renderSystemPrompt renders the system prompt template.
2307func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002308 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002309 ClientGOOS: a.config.ClientGOOS,
2310 ClientGOARCH: a.config.ClientGOARCH,
2311 WorkingDir: a.workingDir,
2312 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002313 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002314 Codebase: a.codebase,
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07002315 UseSketchWIP: a.config.InDocker,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002316 }
David Crawshawc886ac52025-06-13 23:40:03 +00002317 now := time.Now()
2318 if now.Month() == time.September && now.Day() == 19 {
2319 data.SpecialInstruction = "Talk like a pirate to the user. Do not let the priate talk into any code."
2320 }
2321
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002322 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2323 if err != nil {
2324 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2325 }
2326 buf := new(strings.Builder)
2327 err = tmpl.Execute(buf, data)
2328 if err != nil {
2329 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2330 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002331 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002332 return buf.String()
2333}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002334
2335// StateTransitionIterator provides an iterator over state transitions.
2336type StateTransitionIterator interface {
2337 // Next blocks until a new state transition is available or context is done.
2338 // Returns nil if the context is cancelled.
2339 Next() *StateTransition
2340 // Close removes the listener and cleans up resources.
2341 Close()
2342}
2343
2344// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2345type StateTransitionIteratorImpl struct {
2346 agent *Agent
2347 ctx context.Context
2348 ch chan StateTransition
2349 unsubscribe func()
2350}
2351
2352// Next blocks until a new state transition is available or the context is cancelled.
2353func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2354 select {
2355 case <-s.ctx.Done():
2356 return nil
2357 case transition, ok := <-s.ch:
2358 if !ok {
2359 return nil
2360 }
2361 transitionCopy := transition
2362 return &transitionCopy
2363 }
2364}
2365
2366// Close removes the listener and cleans up resources.
2367func (s *StateTransitionIteratorImpl) Close() {
2368 if s.unsubscribe != nil {
2369 s.unsubscribe()
2370 s.unsubscribe = nil
2371 }
2372}
2373
2374// NewStateTransitionIterator returns an iterator that receives state transitions.
2375func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2376 a.mu.Lock()
2377 defer a.mu.Unlock()
2378
2379 // Create channel to receive state transitions
2380 ch := make(chan StateTransition, 10)
2381
2382 // Add a listener to the state machine
2383 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2384
2385 return &StateTransitionIteratorImpl{
2386 agent: a,
2387 ctx: ctx,
2388 ch: ch,
2389 unsubscribe: unsubscribe,
2390 }
2391}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002392
2393// setupGitHooks creates or updates git hooks in the specified working directory.
2394func setupGitHooks(workingDir string) error {
2395 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2396
2397 _, err := os.Stat(hooksDir)
2398 if os.IsNotExist(err) {
2399 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2400 }
2401 if err != nil {
2402 return fmt.Errorf("error checking git hooks directory: %w", err)
2403 }
2404
2405 // Define the post-commit hook content
2406 postCommitHook := `#!/bin/bash
2407echo "<post_commit_hook>"
2408echo "Please review this commit message and fix it if it is incorrect."
2409echo "This hook only echos the commit message; it does not modify it."
2410echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2411echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002412PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002413echo "</last_commit_message>"
2414echo "</post_commit_hook>"
2415`
2416
2417 // Define the prepare-commit-msg hook content
2418 prepareCommitMsgHook := `#!/bin/bash
2419# Add Co-Authored-By and Change-ID trailers to commit messages
2420# Check if these trailers already exist before adding them
2421
2422commit_file="$1"
2423COMMIT_SOURCE="$2"
2424
2425# Skip for merges, squashes, or when using a commit template
2426if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2427 [ "$COMMIT_SOURCE" = "squash" ]; then
2428 exit 0
2429fi
2430
2431commit_msg=$(cat "$commit_file")
2432
2433needs_co_author=true
2434needs_change_id=true
2435
2436# Check if commit message already has Co-Authored-By trailer
2437if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2438 needs_co_author=false
2439fi
2440
2441# Check if commit message already has Change-ID trailer
2442if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2443 needs_change_id=false
2444fi
2445
2446# Only modify if at least one trailer needs to be added
2447if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002448 # Ensure there's a proper blank line before trailers
2449 if [ -s "$commit_file" ]; then
2450 # Check if file ends with newline by reading last character
2451 last_char=$(tail -c 1 "$commit_file")
2452
2453 if [ "$last_char" != "" ]; then
2454 # File doesn't end with newline - add two newlines (complete line + blank line)
2455 echo "" >> "$commit_file"
2456 echo "" >> "$commit_file"
2457 else
2458 # File ends with newline - check if we already have a blank line
2459 last_line=$(tail -1 "$commit_file")
2460 if [ -n "$last_line" ]; then
2461 # Last line has content - add one newline for blank line
2462 echo "" >> "$commit_file"
2463 fi
2464 # If last line is empty, we already have a blank line - don't add anything
2465 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002466 fi
2467
2468 # Add trailers if needed
2469 if [ "$needs_co_author" = true ]; then
2470 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2471 fi
2472
2473 if [ "$needs_change_id" = true ]; then
2474 change_id=$(openssl rand -hex 8)
2475 echo "Change-ID: s${change_id}k" >> "$commit_file"
2476 fi
2477fi
2478`
2479
2480 // Update or create the post-commit hook
2481 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2482 if err != nil {
2483 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2484 }
2485
2486 // Update or create the prepare-commit-msg hook
2487 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2488 if err != nil {
2489 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2490 }
2491
2492 return nil
2493}
2494
2495// updateOrCreateHook creates a new hook file or updates an existing one
2496// by appending the new content if it doesn't already contain it.
2497func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2498 // Check if the hook already exists
2499 buf, err := os.ReadFile(hookPath)
2500 if os.IsNotExist(err) {
2501 // Hook doesn't exist, create it
2502 err = os.WriteFile(hookPath, []byte(content), 0o755)
2503 if err != nil {
2504 return fmt.Errorf("failed to create hook: %w", err)
2505 }
2506 return nil
2507 }
2508 if err != nil {
2509 return fmt.Errorf("error reading existing hook: %w", err)
2510 }
2511
2512 // Hook exists, check if our content is already in it by looking for a distinctive line
2513 code := string(buf)
2514 if strings.Contains(code, distinctiveLine) {
2515 // Already contains our content, nothing to do
2516 return nil
2517 }
2518
2519 // Append our content to the existing hook
2520 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2521 if err != nil {
2522 return fmt.Errorf("failed to open hook for appending: %w", err)
2523 }
2524 defer f.Close()
2525
2526 // Ensure there's a newline at the end of the existing content if needed
2527 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2528 _, err = f.WriteString("\n")
2529 if err != nil {
2530 return fmt.Errorf("failed to add newline to hook: %w", err)
2531 }
2532 }
2533
2534 // Add a separator before our content
2535 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2536 if err != nil {
2537 return fmt.Errorf("failed to append to hook: %w", err)
2538 }
2539
2540 return nil
2541}
Sean McCullough138ec242025-06-02 22:42:06 +00002542
2543// GetPortMonitor returns the port monitor instance for accessing port events
2544func (a *Agent) GetPortMonitor() *PortMonitor {
2545 return a.portMonitor
2546}
Philip Zeyliger0113be52025-06-07 23:53:41 +00002547
2548// SkabandAddr returns the skaband address if configured
2549func (a *Agent) SkabandAddr() string {
2550 if a.config.SkabandClient != nil {
2551 return a.config.SkabandClient.Addr()
2552 }
2553 return ""
2554}