blob: ed5c907937a8876e5e17e754ccd8a7aba0ba73aa [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package loop
2
3import (
4 "context"
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07005 _ "embed"
Earl Lee2e463fb2025-04-17 11:22:22 -07006 "encoding/json"
7 "fmt"
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +00008 "io"
Earl Lee2e463fb2025-04-17 11:22:22 -07009 "log/slog"
10 "net/http"
11 "os"
12 "os/exec"
Pokey Rule7a113622025-05-12 10:58:45 +010013 "path/filepath"
Earl Lee2e463fb2025-04-17 11:22:22 -070014 "runtime/debug"
15 "slices"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070016 "strconv"
Earl Lee2e463fb2025-04-17 11:22:22 -070017 "strings"
18 "sync"
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +000019 "text/template"
Earl Lee2e463fb2025-04-17 11:22:22 -070020 "time"
21
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000022 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070023 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000024 "sketch.dev/claudetool/bashkit"
Autoformatter4962f152025-05-06 17:24:20 +000025 "sketch.dev/claudetool/browse"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000026 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydera997be62025-05-07 22:52:46 +000027 "sketch.dev/claudetool/onstart"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070028 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070029 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070030 "sketch.dev/llm/conversation"
Philip Zeyliger194bfa82025-06-24 06:03:06 -070031 "sketch.dev/mcp"
Philip Zeyligerc17ffe32025-06-05 19:49:13 -070032 "sketch.dev/skabandclient"
Philip Zeyliger5f26a342025-07-04 01:30:29 +000033 "tailscale.com/portlist"
Earl Lee2e463fb2025-04-17 11:22:22 -070034)
35
36const (
37 userCancelMessage = "user requested agent to stop handling responses"
38)
39
Philip Zeyligerb7c58752025-05-01 10:10:17 -070040type MessageIterator interface {
41 // Next blocks until the next message is available. It may
42 // return nil if the underlying iterator context is done.
43 Next() *AgentMessage
44 Close()
45}
46
Earl Lee2e463fb2025-04-17 11:22:22 -070047type CodingAgent interface {
48 // Init initializes an agent inside a docker container.
49 Init(AgentInit) error
50
51 // Ready returns a channel closed after Init successfully called.
52 Ready() <-chan struct{}
53
54 // URL reports the HTTP URL of this agent.
55 URL() string
56
57 // UserMessage enqueues a message to the agent and returns immediately.
58 UserMessage(ctx context.Context, msg string)
59
Philip Zeyligerb7c58752025-05-01 10:10:17 -070060 // Returns an iterator that finishes when the context is done and
61 // starts with the given message index.
62 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070063
Philip Zeyligereab12de2025-05-14 02:35:53 +000064 // Returns an iterator that notifies of state transitions until the context is done.
65 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
66
Earl Lee2e463fb2025-04-17 11:22:22 -070067 // Loop begins the agent loop returns only when ctx is cancelled.
68 Loop(ctx context.Context)
69
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000070 // BranchPrefix returns the configured branch prefix
71 BranchPrefix() string
72
philip.zeyliger6d3de482025-06-10 19:38:14 -070073 // LinkToGitHub returns whether GitHub branch linking is enabled
74 LinkToGitHub() bool
75
Sean McCulloughedc88dc2025-04-30 02:55:01 +000076 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070077
78 CancelToolUse(toolUseID string, cause error) error
79
80 // Returns a subset of the agent's message history.
81 Messages(start int, end int) []AgentMessage
82
83 // Returns the current number of messages in the history
84 MessageCount() int
85
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070086 TotalUsage() conversation.CumulativeUsage
87 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070088
Earl Lee2e463fb2025-04-17 11:22:22 -070089 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000090 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070091
92 // Diff returns a unified diff of changes made since the agent was instantiated.
93 // If commit is non-nil, it shows the diff for just that specific commit.
94 Diff(commit *string) (string, error)
95
Philip Zeyliger49edc922025-05-14 09:45:45 -070096 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
97 // starts out as the commit where sketch started, but a user can move it if need
98 // be, for example in the case of a rebase. It is stored as a git tag.
99 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700100
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000101 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
102 // (Typically, this is "sketch-base")
103 SketchGitBaseRef() string
104
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700105 // Slug returns the slug identifier for this session.
106 Slug() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700107
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000108 // BranchName returns the git branch name for the conversation.
109 BranchName() string
110
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700111 // IncrementRetryNumber increments the retry number for branch naming conflicts.
112 IncrementRetryNumber()
113
Earl Lee2e463fb2025-04-17 11:22:22 -0700114 // OS returns the operating system of the client.
115 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000116
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000117 // SessionID returns the unique session identifier.
118 SessionID() string
119
philip.zeyliger8773e682025-06-11 21:36:21 -0700120 // SSHConnectionString returns the SSH connection string for the container.
121 SSHConnectionString() string
122
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000123 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700124 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000125
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000126 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
127 OutstandingLLMCallCount() int
128
129 // OutstandingToolCalls returns the names of outstanding tool calls.
130 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000131 OutsideOS() string
132 OutsideHostname() string
133 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000134 GitOrigin() string
Philip Zeyliger64f60462025-06-16 13:57:10 -0700135
bankseancad67b02025-06-27 21:57:05 +0000136 // GitUsername returns the git user name from the agent config.
137 GitUsername() string
138
Philip Zeyliger64f60462025-06-16 13:57:10 -0700139 // DiffStats returns the number of lines added and removed from sketch-base to HEAD
140 DiffStats() (int, int)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000141 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
142 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700143
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700144 // IsInContainer returns true if the agent is running in a container
145 IsInContainer() bool
146 // FirstMessageIndex returns the index of the first message in the current conversation
147 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700148
149 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700150 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
151 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700152
153 // CompactConversation compacts the current conversation by generating a summary
154 // and restarting the conversation with that summary as the initial context
155 CompactConversation(ctx context.Context) error
Philip Zeyligerda623b52025-07-04 01:12:38 +0000156
Philip Zeyliger0113be52025-06-07 23:53:41 +0000157 // SkabandAddr returns the skaband address if configured
158 SkabandAddr() string
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000159
160 // GetPorts returns the cached list of open TCP ports
161 GetPorts() []portlist.Port
banksean5ab8fb82025-07-09 12:34:55 -0700162
163 // TokenContextWindow returns the TokenContextWindow size of the model the agent is using.
164 TokenContextWindow() int
Earl Lee2e463fb2025-04-17 11:22:22 -0700165}
166
167type CodingAgentMessageType string
168
169const (
170 UserMessageType CodingAgentMessageType = "user"
171 AgentMessageType CodingAgentMessageType = "agent"
172 ErrorMessageType CodingAgentMessageType = "error"
173 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
174 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700175 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
176 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
177 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000178 PortMessageType CodingAgentMessageType = "port" // for port monitoring events
Earl Lee2e463fb2025-04-17 11:22:22 -0700179
180 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
181)
182
183type AgentMessage struct {
184 Type CodingAgentMessageType `json:"type"`
185 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
186 EndOfTurn bool `json:"end_of_turn"`
187
188 Content string `json:"content"`
189 ToolName string `json:"tool_name,omitempty"`
190 ToolInput string `json:"input,omitempty"`
191 ToolResult string `json:"tool_result,omitempty"`
192 ToolError bool `json:"tool_error,omitempty"`
193 ToolCallId string `json:"tool_call_id,omitempty"`
194
195 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
196 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
197
Sean McCulloughd9f13372025-04-21 15:08:49 -0700198 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
199 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
200
Earl Lee2e463fb2025-04-17 11:22:22 -0700201 // Commits is a list of git commits for a commit message
202 Commits []*GitCommit `json:"commits,omitempty"`
203
204 Timestamp time.Time `json:"timestamp"`
205 ConversationID string `json:"conversation_id"`
206 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700207 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700208
209 // Message timing information
210 StartTime *time.Time `json:"start_time,omitempty"`
211 EndTime *time.Time `json:"end_time,omitempty"`
212 Elapsed *time.Duration `json:"elapsed,omitempty"`
213
214 // Turn duration - the time taken for a complete agent turn
215 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
216
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000217 // HideOutput indicates that this message should not be rendered in the UI.
218 // This is useful for subconversations that generate output that shouldn't be shown to the user.
219 HideOutput bool `json:"hide_output,omitempty"`
220
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700221 // TodoContent contains the agent's todo file content when it has changed
222 TodoContent *string `json:"todo_content,omitempty"`
223
Earl Lee2e463fb2025-04-17 11:22:22 -0700224 Idx int `json:"idx"`
225}
226
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000227// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700228func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700229 if convo == nil {
230 m.ConversationID = ""
231 m.ParentConversationID = nil
232 return
233 }
234 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000235 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700236 if convo.Parent != nil {
237 m.ParentConversationID = &convo.Parent.ID
238 }
239}
240
Earl Lee2e463fb2025-04-17 11:22:22 -0700241// GitCommit represents a single git commit for a commit message
242type GitCommit struct {
243 Hash string `json:"hash"` // Full commit hash
244 Subject string `json:"subject"` // Commit subject line
245 Body string `json:"body"` // Full commit message body
246 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
247}
248
249// ToolCall represents a single tool call within an agent message
250type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700251 Name string `json:"name"`
252 Input string `json:"input"`
253 ToolCallId string `json:"tool_call_id"`
254 ResultMessage *AgentMessage `json:"result_message,omitempty"`
255 Args string `json:"args,omitempty"`
256 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700257}
258
259func (a *AgentMessage) Attr() slog.Attr {
260 var attrs []any = []any{
261 slog.String("type", string(a.Type)),
262 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700263 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700264 if a.EndOfTurn {
265 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
266 }
267 if a.Content != "" {
268 attrs = append(attrs, slog.String("content", a.Content))
269 }
270 if a.ToolName != "" {
271 attrs = append(attrs, slog.String("tool_name", a.ToolName))
272 }
273 if a.ToolInput != "" {
274 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
275 }
276 if a.Elapsed != nil {
277 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
278 }
279 if a.TurnDuration != nil {
280 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
281 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700282 if len(a.ToolResult) > 0 {
283 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700284 }
285 if a.ToolError {
286 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
287 }
288 if len(a.ToolCalls) > 0 {
289 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
290 for i, tc := range a.ToolCalls {
291 toolCallAttrs = append(toolCallAttrs, slog.Group(
292 fmt.Sprintf("tool_call_%d", i),
293 slog.String("name", tc.Name),
294 slog.String("input", tc.Input),
295 ))
296 }
297 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
298 }
299 if a.ConversationID != "" {
300 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
301 }
302 if a.ParentConversationID != nil {
303 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
304 }
305 if a.Usage != nil && !a.Usage.IsZero() {
306 attrs = append(attrs, a.Usage.Attr())
307 }
308 // TODO: timestamp, convo ids, idx?
309 return slog.Group("agent_message", attrs...)
310}
311
312func errorMessage(err error) AgentMessage {
313 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
314 if os.Getenv(("DEBUG")) == "1" {
315 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
316 }
317
318 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
319}
320
321func budgetMessage(err error) AgentMessage {
322 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
323}
324
325// ConvoInterface defines the interface for conversation interactions
326type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700327 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700328 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700329 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700330 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700331 SendMessage(message llm.Message) (*llm.Response, error)
332 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700333 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000334 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700335 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700336 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700337 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700338}
339
Philip Zeyligerf2872992025-05-22 10:35:28 -0700340// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700341// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700342// any time we notice we need to.
343type AgentGitState struct {
344 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700345 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700346 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000347 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700348 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700349 slug string // Human-readable session identifier
350 retryNumber int // Number to append when branch conflicts occur
Philip Zeyliger64f60462025-06-16 13:57:10 -0700351 linesAdded int // Lines added from sketch-base to HEAD
352 linesRemoved int // Lines removed from sketch-base to HEAD
Philip Zeyligerf2872992025-05-22 10:35:28 -0700353}
354
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700355func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700356 ags.mu.Lock()
357 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700358 if ags.slug != slug {
359 ags.retryNumber = 0
360 }
361 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700362}
363
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700364func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700365 ags.mu.Lock()
366 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700367 return ags.slug
368}
369
370func (ags *AgentGitState) IncrementRetryNumber() {
371 ags.mu.Lock()
372 defer ags.mu.Unlock()
373 ags.retryNumber++
374}
375
Philip Zeyliger64f60462025-06-16 13:57:10 -0700376func (ags *AgentGitState) DiffStats() (int, int) {
377 ags.mu.Lock()
378 defer ags.mu.Unlock()
379 return ags.linesAdded, ags.linesRemoved
380}
381
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700382// HasSeenCommits returns true if any commits have been processed
383func (ags *AgentGitState) HasSeenCommits() bool {
384 ags.mu.Lock()
385 defer ags.mu.Unlock()
386 return len(ags.seenCommits) > 0
387}
388
389func (ags *AgentGitState) RetryNumber() int {
390 ags.mu.Lock()
391 defer ags.mu.Unlock()
392 return ags.retryNumber
393}
394
395func (ags *AgentGitState) BranchName(prefix string) string {
396 ags.mu.Lock()
397 defer ags.mu.Unlock()
398 return ags.branchNameLocked(prefix)
399}
400
401func (ags *AgentGitState) branchNameLocked(prefix string) string {
402 if ags.slug == "" {
403 return ""
404 }
405 if ags.retryNumber == 0 {
406 return prefix + ags.slug
407 }
408 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700409}
410
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000411func (ags *AgentGitState) Upstream() string {
412 ags.mu.Lock()
413 defer ags.mu.Unlock()
414 return ags.upstream
415}
416
Earl Lee2e463fb2025-04-17 11:22:22 -0700417type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700418 convo ConvoInterface
419 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700420 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700421 workingDir string
422 repoRoot string // workingDir may be a subdir of repoRoot
423 url string
424 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000425 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700426 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000427 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700428 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700429 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000430 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700431 // State machine to track agent state
432 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000433 // Outside information
434 outsideHostname string
435 outsideOS string
436 outsideWorkingDir string
Philip Zeyliger194bfa82025-06-24 06:03:06 -0700437 // MCP manager for handling MCP server connections
438 mcpManager *mcp.MCPManager
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000439 // Port monitor for tracking TCP ports
440 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700441
442 // Time when the current turn started (reset at the beginning of InnerLoop)
443 startOfTurn time.Time
444
445 // Inbox - for messages from the user to the agent.
446 // sent on by UserMessage
447 // . e.g. when user types into the chat textarea
448 // read from by GatherMessages
449 inbox chan string
450
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000451 // protects cancelTurn
452 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700453 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000454 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700455
456 // protects following
457 mu sync.Mutex
458
459 // Stores all messages for this agent
460 history []AgentMessage
461
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700462 // Iterators add themselves here when they're ready to be notified of new messages.
463 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700464
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000465 // Track outstanding LLM call IDs
466 outstandingLLMCalls map[string]struct{}
467
468 // Track outstanding tool calls by ID with their names
469 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700470}
471
banksean5ab8fb82025-07-09 12:34:55 -0700472// TokenContextWindow implements CodingAgent.
473func (a *Agent) TokenContextWindow() int {
474 return a.config.Service.TokenContextWindow()
475}
476
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700477// NewIterator implements CodingAgent.
478func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
479 a.mu.Lock()
480 defer a.mu.Unlock()
481
482 return &MessageIteratorImpl{
483 agent: a,
484 ctx: ctx,
485 nextMessageIdx: nextMessageIdx,
486 ch: make(chan *AgentMessage, 100),
487 }
488}
489
490type MessageIteratorImpl struct {
491 agent *Agent
492 ctx context.Context
493 nextMessageIdx int
494 ch chan *AgentMessage
495 subscribed bool
496}
497
498func (m *MessageIteratorImpl) Close() {
499 m.agent.mu.Lock()
500 defer m.agent.mu.Unlock()
501 // Delete ourselves from the subscribers list
502 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
503 return x == m.ch
504 })
505 close(m.ch)
506}
507
508func (m *MessageIteratorImpl) Next() *AgentMessage {
509 // We avoid subscription at creation to let ourselves catch up to "current state"
510 // before subscribing.
511 if !m.subscribed {
512 m.agent.mu.Lock()
513 if m.nextMessageIdx < len(m.agent.history) {
514 msg := &m.agent.history[m.nextMessageIdx]
515 m.nextMessageIdx++
516 m.agent.mu.Unlock()
517 return msg
518 }
519 // The next message doesn't exist yet, so let's subscribe
520 m.agent.subscribers = append(m.agent.subscribers, m.ch)
521 m.subscribed = true
522 m.agent.mu.Unlock()
523 }
524
525 for {
526 select {
527 case <-m.ctx.Done():
528 m.agent.mu.Lock()
529 // Delete ourselves from the subscribers list
530 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
531 return x == m.ch
532 })
533 m.subscribed = false
534 m.agent.mu.Unlock()
535 return nil
536 case msg, ok := <-m.ch:
537 if !ok {
538 // Close may have been called
539 return nil
540 }
541 if msg.Idx == m.nextMessageIdx {
542 m.nextMessageIdx++
543 return msg
544 }
545 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
546 panic("out of order message")
547 }
548 }
549}
550
Sean McCulloughd9d45812025-04-30 16:53:41 -0700551// Assert that Agent satisfies the CodingAgent interface.
552var _ CodingAgent = &Agent{}
553
554// StateName implements CodingAgent.
555func (a *Agent) CurrentStateName() string {
556 if a.stateMachine == nil {
557 return ""
558 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000559 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700560}
561
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700562// CurrentTodoContent returns the current todo list data as JSON.
563// It returns an empty string if no todos exist.
564func (a *Agent) CurrentTodoContent() string {
565 todoPath := claudetool.TodoFilePath(a.config.SessionID)
566 content, err := os.ReadFile(todoPath)
567 if err != nil {
568 return ""
569 }
570 return string(content)
571}
572
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700573// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
574func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
575 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.
576
577IMPORTANT: 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.
578
579Please create a detailed summary that includes:
580
5811. **User's Request**: What did the user originally ask me to do? What was their goal?
582
5832. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
584
5853. **Key Technical Decisions**: What important technical choices were made during our work and why?
586
5874. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
588
5895. **Next Steps**: What still needs to be done to complete the user's request?
590
5916. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
592
593Focus 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.
594
595Reply with ONLY the summary content - no meta-commentary about creating the summary.`
596
597 userMessage := llm.UserStringMessage(msg)
598 // Use a subconversation with history to get the summary
599 // TODO: We don't have any tools here, so we should have enough tokens
600 // to capture a summary, but we may need to modify the history (e.g., remove
601 // TODO data) to save on some tokens.
602 convo := a.convo.SubConvoWithHistory()
603
604 // Modify the system prompt to provide context about the original task
605 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000606 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 -0700607
608Your 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.
609
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000610Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700611
612 resp, err := convo.SendMessage(userMessage)
613 if err != nil {
614 a.pushToOutbox(ctx, errorMessage(err))
615 return "", err
616 }
617 textContent := collectTextContent(resp)
618
619 // Restore original system prompt (though this subconvo will be discarded)
620 convo.SystemPrompt = originalSystemPrompt
621
622 return textContent, nil
623}
624
625// CompactConversation compacts the current conversation by generating a summary
626// and restarting the conversation with that summary as the initial context
627func (a *Agent) CompactConversation(ctx context.Context) error {
628 summary, err := a.generateConversationSummary(ctx)
629 if err != nil {
630 return fmt.Errorf("failed to generate conversation summary: %w", err)
631 }
632
633 a.mu.Lock()
634
635 // Get usage information before resetting conversation
636 lastUsage := a.convo.LastUsage()
637 contextWindow := a.config.Service.TokenContextWindow()
638 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
639
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000640 // Preserve cumulative usage across compaction
641 cumulativeUsage := a.convo.CumulativeUsage()
642
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700643 // Reset conversation state but keep all other state (git, working dir, etc.)
644 a.firstMessageIndex = len(a.history)
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000645 a.convo = a.initConvoWithUsage(&cumulativeUsage)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700646
647 a.mu.Unlock()
648
649 // Create informative compaction message with token details
650 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
651 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
652 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
653
654 a.pushToOutbox(ctx, AgentMessage{
655 Type: CompactMessageType,
656 Content: compactionMsg,
657 })
658
659 a.pushToOutbox(ctx, AgentMessage{
660 Type: UserMessageType,
661 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),
662 })
663 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)
664
665 return nil
666}
667
Earl Lee2e463fb2025-04-17 11:22:22 -0700668func (a *Agent) URL() string { return a.url }
669
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000670// GetPorts returns the cached list of open TCP ports.
671func (a *Agent) GetPorts() []portlist.Port {
672 if a.portMonitor == nil {
673 return nil
674 }
675 return a.portMonitor.GetPorts()
676}
677
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000678// BranchName returns the git branch name for the conversation.
679func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700680 return a.gitState.BranchName(a.config.BranchPrefix)
681}
682
683// Slug returns the slug identifier for this conversation.
684func (a *Agent) Slug() string {
685 return a.gitState.Slug()
686}
687
688// IncrementRetryNumber increments the retry number for branch naming conflicts
689func (a *Agent) IncrementRetryNumber() {
690 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000691}
692
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000693// OutstandingLLMCallCount returns the number of outstanding LLM calls.
694func (a *Agent) OutstandingLLMCallCount() int {
695 a.mu.Lock()
696 defer a.mu.Unlock()
697 return len(a.outstandingLLMCalls)
698}
699
700// OutstandingToolCalls returns the names of outstanding tool calls.
701func (a *Agent) OutstandingToolCalls() []string {
702 a.mu.Lock()
703 defer a.mu.Unlock()
704
705 tools := make([]string, 0, len(a.outstandingToolCalls))
706 for _, toolName := range a.outstandingToolCalls {
707 tools = append(tools, toolName)
708 }
709 return tools
710}
711
Earl Lee2e463fb2025-04-17 11:22:22 -0700712// OS returns the operating system of the client.
713func (a *Agent) OS() string {
714 return a.config.ClientGOOS
715}
716
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000717func (a *Agent) SessionID() string {
718 return a.config.SessionID
719}
720
philip.zeyliger8773e682025-06-11 21:36:21 -0700721// SSHConnectionString returns the SSH connection string for the container.
722func (a *Agent) SSHConnectionString() string {
723 return a.config.SSHConnectionString
724}
725
Philip Zeyliger18532b22025-04-23 21:11:46 +0000726// OutsideOS returns the operating system of the outside system.
727func (a *Agent) OutsideOS() string {
728 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000729}
730
Philip Zeyliger18532b22025-04-23 21:11:46 +0000731// OutsideHostname returns the hostname of the outside system.
732func (a *Agent) OutsideHostname() string {
733 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000734}
735
Philip Zeyliger18532b22025-04-23 21:11:46 +0000736// OutsideWorkingDir returns the working directory on the outside system.
737func (a *Agent) OutsideWorkingDir() string {
738 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000739}
740
741// GitOrigin returns the URL of the git remote 'origin' if it exists.
742func (a *Agent) GitOrigin() string {
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +0000743 return a.config.OriginalGitOrigin
Philip Zeyligerd1402952025-04-23 03:54:37 +0000744}
745
bankseancad67b02025-06-27 21:57:05 +0000746// GitUsername returns the git user name from the agent config.
747func (a *Agent) GitUsername() string {
748 return a.config.GitUsername
749}
750
Philip Zeyliger64f60462025-06-16 13:57:10 -0700751// DiffStats returns the number of lines added and removed from sketch-base to HEAD
752func (a *Agent) DiffStats() (int, int) {
753 return a.gitState.DiffStats()
754}
755
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000756func (a *Agent) OpenBrowser(url string) {
757 if !a.IsInContainer() {
758 browser.Open(url)
759 return
760 }
761 // We're in Docker, need to send a request to the Git server
762 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700763 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000764 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700765 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000766 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700767 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000768 return
769 }
770 defer resp.Body.Close()
771 if resp.StatusCode == http.StatusOK {
772 return
773 }
774 body, _ := io.ReadAll(resp.Body)
775 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
776}
777
Sean McCullough96b60dd2025-04-30 09:49:10 -0700778// CurrentState returns the current state of the agent's state machine.
779func (a *Agent) CurrentState() State {
780 return a.stateMachine.CurrentState()
781}
782
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700783func (a *Agent) IsInContainer() bool {
784 return a.config.InDocker
785}
786
787func (a *Agent) FirstMessageIndex() int {
788 a.mu.Lock()
789 defer a.mu.Unlock()
790 return a.firstMessageIndex
791}
792
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700793// SetSlug sets a human-readable identifier for the conversation.
794func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700795 a.mu.Lock()
796 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700797
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700798 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000799 convo, ok := a.convo.(*conversation.Convo)
800 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700801 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000802 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700803}
804
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000805// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700806func (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 +0000807 // Track the tool call
808 a.mu.Lock()
809 a.outstandingToolCalls[id] = toolName
810 a.mu.Unlock()
811}
812
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700813// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
814// If there's only one element in the array and it's a text type, it returns that text directly.
815// It also processes nested ToolResult arrays recursively.
816func contentToString(contents []llm.Content) string {
817 if len(contents) == 0 {
818 return ""
819 }
820
821 // If there's only one element and it's a text type, return it directly
822 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
823 return contents[0].Text
824 }
825
826 // Otherwise, concatenate all text content
827 var result strings.Builder
828 for _, content := range contents {
829 if content.Type == llm.ContentTypeText {
830 result.WriteString(content.Text)
831 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
832 // Recursively process nested tool results
833 result.WriteString(contentToString(content.ToolResult))
834 }
835 }
836
837 return result.String()
838}
839
Earl Lee2e463fb2025-04-17 11:22:22 -0700840// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700841func (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 +0000842 // Remove the tool call from outstanding calls
843 a.mu.Lock()
844 delete(a.outstandingToolCalls, toolID)
845 a.mu.Unlock()
846
Earl Lee2e463fb2025-04-17 11:22:22 -0700847 m := AgentMessage{
848 Type: ToolUseMessageType,
849 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700850 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700851 ToolError: content.ToolError,
852 ToolName: toolName,
853 ToolInput: string(toolInput),
854 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700855 StartTime: content.ToolUseStartTime,
856 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700857 }
858
859 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700860 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
861 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700862 m.Elapsed = &elapsed
863 }
864
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700865 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700866 a.pushToOutbox(ctx, m)
867}
868
869// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700870func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000871 a.mu.Lock()
872 defer a.mu.Unlock()
873 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700874 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
875}
876
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700877// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700878// that need to be displayed (as well as tool calls that we send along when
879// they're done). (It would be reasonable to also mention tool calls when they're
880// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700881func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000882 // Remove the LLM call from outstanding calls
883 a.mu.Lock()
884 delete(a.outstandingLLMCalls, id)
885 a.mu.Unlock()
886
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700887 if resp == nil {
888 // LLM API call failed
889 m := AgentMessage{
890 Type: ErrorMessageType,
891 Content: "API call failed, type 'continue' to try again",
892 }
893 m.SetConvo(convo)
894 a.pushToOutbox(ctx, m)
895 return
896 }
897
Earl Lee2e463fb2025-04-17 11:22:22 -0700898 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700899 if convo.Parent == nil { // subconvos never end the turn
900 switch resp.StopReason {
901 case llm.StopReasonToolUse:
902 // Check whether any of the tool calls are for tools that should end the turn
903 ToolSearch:
904 for _, part := range resp.Content {
905 if part.Type != llm.ContentTypeToolUse {
906 continue
907 }
Sean McCullough021557a2025-05-05 23:20:53 +0000908 // Find the tool by name
909 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700910 if tool.Name == part.ToolName {
911 endOfTurn = tool.EndsTurn
912 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000913 }
914 }
Sean McCullough021557a2025-05-05 23:20:53 +0000915 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700916 default:
917 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000918 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700919 }
920 m := AgentMessage{
921 Type: AgentMessageType,
922 Content: collectTextContent(resp),
923 EndOfTurn: endOfTurn,
924 Usage: &resp.Usage,
925 StartTime: resp.StartTime,
926 EndTime: resp.EndTime,
927 }
928
929 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700930 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700931 var toolCalls []ToolCall
932 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700933 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700934 toolCalls = append(toolCalls, ToolCall{
935 Name: part.ToolName,
936 Input: string(part.ToolInput),
937 ToolCallId: part.ID,
938 })
939 }
940 }
941 m.ToolCalls = toolCalls
942 }
943
944 // Calculate the elapsed time if both start and end times are set
945 if resp.StartTime != nil && resp.EndTime != nil {
946 elapsed := resp.EndTime.Sub(*resp.StartTime)
947 m.Elapsed = &elapsed
948 }
949
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700950 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700951 a.pushToOutbox(ctx, m)
952}
953
954// WorkingDir implements CodingAgent.
955func (a *Agent) WorkingDir() string {
956 return a.workingDir
957}
958
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000959// RepoRoot returns the git repository root directory.
960func (a *Agent) RepoRoot() string {
961 return a.repoRoot
962}
963
Earl Lee2e463fb2025-04-17 11:22:22 -0700964// MessageCount implements CodingAgent.
965func (a *Agent) MessageCount() int {
966 a.mu.Lock()
967 defer a.mu.Unlock()
968 return len(a.history)
969}
970
971// Messages implements CodingAgent.
972func (a *Agent) Messages(start int, end int) []AgentMessage {
973 a.mu.Lock()
974 defer a.mu.Unlock()
975 return slices.Clone(a.history[start:end])
976}
977
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700978// ShouldCompact checks if the conversation should be compacted based on token usage
979func (a *Agent) ShouldCompact() bool {
980 // Get the threshold from environment variable, default to 0.94 (94%)
981 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
982 // and a little bit of buffer.)
983 thresholdRatio := 0.94
984 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
985 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
986 thresholdRatio = parsed
987 }
988 }
989
990 // Get the most recent usage to check current context size
991 lastUsage := a.convo.LastUsage()
992
993 if lastUsage.InputTokens == 0 {
994 // No API calls made yet
995 return false
996 }
997
998 // Calculate the current context size from the last API call
999 // This includes all tokens that were part of the input context:
1000 // - Input tokens (user messages, system prompt, conversation history)
1001 // - Cache read tokens (cached parts of the context)
1002 // - Cache creation tokens (new parts being cached)
1003 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
1004
1005 // Get the service's token context window
1006 service := a.config.Service
1007 contextWindow := service.TokenContextWindow()
1008
1009 // Calculate threshold
1010 threshold := uint64(float64(contextWindow) * thresholdRatio)
1011
1012 // Check if we've exceeded the threshold
1013 return currentContextSize >= threshold
1014}
1015
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001016func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -07001017 return a.originalBudget
1018}
1019
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001020// Upstream returns the upstream branch for git work
1021func (a *Agent) Upstream() string {
1022 return a.gitState.Upstream()
1023}
1024
Earl Lee2e463fb2025-04-17 11:22:22 -07001025// AgentConfig contains configuration for creating a new Agent.
1026type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001027 Context context.Context
1028 Service llm.Service
1029 Budget conversation.Budget
1030 GitUsername string
1031 GitEmail string
1032 SessionID string
1033 ClientGOOS string
1034 ClientGOARCH string
1035 InDocker bool
1036 OneShot bool
1037 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +00001038 // Outside information
1039 OutsideHostname string
1040 OutsideOS string
1041 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001042
1043 // Outtie's HTTP to, e.g., open a browser
1044 OutsideHTTP string
1045 // Outtie's Git server
1046 GitRemoteAddr string
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001047 // Original git origin URL from host repository, if any
1048 OriginalGitOrigin string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001049 // Upstream branch for git work
1050 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001051 // Commit to checkout from Outtie
1052 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001053 // Prefix for git branches created by sketch
1054 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001055 // LinkToGitHub enables GitHub branch linking in UI
1056 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001057 // SSH connection string for connecting to the container
1058 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001059 // Skaband client for session history (optional)
1060 SkabandClient *skabandclient.SkabandClient
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001061 // MCP server configurations
1062 MCPServers []string
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001063 // Timeout configuration for bash tool
1064 BashTimeouts *claudetool.Timeouts
Earl Lee2e463fb2025-04-17 11:22:22 -07001065}
1066
1067// NewAgent creates a new Agent.
1068// It is not usable until Init() is called.
1069func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001070 // Set default branch prefix if not specified
1071 if config.BranchPrefix == "" {
1072 config.BranchPrefix = "sketch/"
1073 }
1074
Earl Lee2e463fb2025-04-17 11:22:22 -07001075 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001076 config: config,
1077 ready: make(chan struct{}),
1078 inbox: make(chan string, 100),
1079 subscribers: make([]chan *AgentMessage, 0),
1080 startedAt: time.Now(),
1081 originalBudget: config.Budget,
1082 gitState: AgentGitState{
1083 seenCommits: make(map[string]bool),
1084 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001085 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001086 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001087 outsideHostname: config.OutsideHostname,
1088 outsideOS: config.OutsideOS,
1089 outsideWorkingDir: config.OutsideWorkingDir,
1090 outstandingLLMCalls: make(map[string]struct{}),
1091 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001092 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001093 workingDir: config.WorkingDir,
1094 outsideHTTP: config.OutsideHTTP,
Philip Zeyligerda623b52025-07-04 01:12:38 +00001095
1096 mcpManager: mcp.NewMCPManager(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001097 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001098
1099 // Initialize port monitor with 5-second interval
1100 agent.portMonitor = NewPortMonitor(agent, 5*time.Second)
1101
Earl Lee2e463fb2025-04-17 11:22:22 -07001102 return agent
1103}
1104
1105type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001106 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001107
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001108 InDocker bool
1109 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001110}
1111
1112func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001113 if a.convo != nil {
1114 return fmt.Errorf("Agent.Init: already initialized")
1115 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001116 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001117 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001118
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001119 // If a remote + commit was specified, clone it.
1120 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
1121 slog.InfoContext(ctx, "cloning git repo", "commit", a.config.Commit)
1122 // TODO: --reference-if-able instead?
1123 cmd := exec.CommandContext(ctx, "git", "clone", "--reference", "/git-ref", a.gitState.gitRemoteAddr, "/app")
1124 if out, err := cmd.CombinedOutput(); err != nil {
1125 return fmt.Errorf("failed to clone repository from %s: %s: %w", a.gitState.gitRemoteAddr, out, err)
1126 }
1127 }
1128
1129 if a.workingDir != "" {
1130 err := os.Chdir(a.workingDir)
1131 if err != nil {
1132 return fmt.Errorf("failed to change working directory to %s: %w", a.workingDir, err)
1133 }
1134 }
1135
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001136 if !ini.NoGit {
Philip Zeyligere1c8b7b2025-07-03 14:50:26 -07001137
1138 // Configure git user settings
1139 if a.config.GitEmail != "" {
1140 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.email", a.config.GitEmail)
1141 cmd.Dir = a.workingDir
1142 if out, err := cmd.CombinedOutput(); err != nil {
1143 return fmt.Errorf("git config --global user.email: %s: %v", out, err)
1144 }
1145 }
1146 if a.config.GitUsername != "" {
1147 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.name", a.config.GitUsername)
1148 cmd.Dir = a.workingDir
1149 if out, err := cmd.CombinedOutput(); err != nil {
1150 return fmt.Errorf("git config --global user.name: %s: %v", out, err)
1151 }
1152 }
1153 // Configure git http.postBuffer
1154 cmd := exec.CommandContext(ctx, "git", "config", "--global", "http.postBuffer", "524288000")
1155 cmd.Dir = a.workingDir
1156 if out, err := cmd.CombinedOutput(); err != nil {
1157 return fmt.Errorf("git config --global http.postBuffer: %s: %v", out, err)
1158 }
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001159 }
1160
Philip Zeyligerf2872992025-05-22 10:35:28 -07001161 // If a commit was specified, we fetch and reset to it.
1162 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001163 slog.InfoContext(ctx, "updating git repo", "commit", a.config.Commit)
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001164
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001165 cmd := exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001166 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001167 if out, err := cmd.CombinedOutput(); err != nil {
1168 return fmt.Errorf("git fetch: %s: %w", out, err)
1169 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001170 // The -B resets the branch if it already exists (or creates it if it doesn't)
1171 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001172 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001173 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1174 // Remove git hooks if they exist and retry
1175 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001176 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001177 if _, statErr := os.Stat(hookPath); statErr == nil {
1178 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1179 slog.String("error", err.Error()),
1180 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001181 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001182 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1183 }
1184
1185 // Retry the checkout operation
Philip Zeyliger1417b692025-06-12 11:07:04 -07001186 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001187 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001188 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001189 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 +01001190 }
1191 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001192 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001193 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001194 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001195 } else if a.IsInContainer() {
1196 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1197 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1198 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1199 cmd.Dir = a.workingDir
1200 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1201 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1202 }
1203 } else {
1204 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001205 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001206
1207 if ini.HostAddr != "" {
1208 a.url = "http://" + ini.HostAddr
1209 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001210
1211 if !ini.NoGit {
1212 repoRoot, err := repoRoot(ctx, a.workingDir)
1213 if err != nil {
1214 return fmt.Errorf("repoRoot: %w", err)
1215 }
1216 a.repoRoot = repoRoot
1217
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001218 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001219 if err := setupGitHooks(a.repoRoot); err != nil {
1220 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1221 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001222 }
1223
Philip Zeyliger49edc922025-05-14 09:45:45 -07001224 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1225 cmd.Dir = repoRoot
1226 if out, err := cmd.CombinedOutput(); err != nil {
1227 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1228 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001229
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001230 slog.Info("running codebase analysis")
1231 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1232 if err != nil {
1233 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001234 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001235 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001236
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001237 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001238 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001239 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001240 }
1241 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001242
Earl Lee2e463fb2025-04-17 11:22:22 -07001243 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001244 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001245 a.convo = a.initConvo()
1246 close(a.ready)
1247 return nil
1248}
1249
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001250//go:embed agent_system_prompt.txt
1251var agentSystemPrompt string
1252
Earl Lee2e463fb2025-04-17 11:22:22 -07001253// initConvo initializes the conversation.
1254// It must not be called until all agent fields are initialized,
1255// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001256func (a *Agent) initConvo() *conversation.Convo {
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001257 return a.initConvoWithUsage(nil)
1258}
1259
1260// initConvoWithUsage initializes the conversation with optional preserved usage.
1261func (a *Agent) initConvoWithUsage(usage *conversation.CumulativeUsage) *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001262 ctx := a.config.Context
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001263 convo := conversation.New(ctx, a.config.Service, usage)
Earl Lee2e463fb2025-04-17 11:22:22 -07001264 convo.PromptCaching = true
1265 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001266 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001267 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001268
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001269 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1270 bashPermissionCheck := func(command string) error {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001271 if a.gitState.Slug() != "" {
1272 return nil // branch is set up
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001273 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001274 willCommit, err := bashkit.WillRunGitCommit(command)
1275 if err != nil {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001276 return nil // fail open
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001277 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001278 if willCommit {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001279 return fmt.Errorf("you must use the set-slug tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001280 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001281 return nil
1282 }
1283
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001284 bashTool := &claudetool.BashTool{
1285 CheckPermission: bashPermissionCheck,
1286 EnableJITInstall: claudetool.EnableBashToolJITInstall,
1287 Timeouts: a.config.BashTimeouts,
1288 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001289
Earl Lee2e463fb2025-04-17 11:22:22 -07001290 // Register all tools with the conversation
1291 // When adding, removing, or modifying tools here, double-check that the termui tool display
1292 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001293
1294 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001295 _, supportsScreenshots := a.config.Service.(*ant.Service)
1296 var bTools []*llm.Tool
1297 var browserCleanup func()
1298
1299 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1300 // Add cleanup function to context cancel
1301 go func() {
1302 <-a.config.Context.Done()
1303 browserCleanup()
1304 }()
1305 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001306
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001307 convo.Tools = []*llm.Tool{
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001308 bashTool.Tool(), claudetool.Keyword, claudetool.Patch(a.patchCallback),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001309 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.setSlugTool(), a.commitMessageStyleTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001310 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001311 }
1312
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001313 // One-shot mode is non-interactive, multiple choice requires human response
1314 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001315 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001316 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001317
1318 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001319
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001320 // Add MCP tools if configured
1321 if len(a.config.MCPServers) > 0 {
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001322
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001323 slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers))
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001324 serverConfigs, parseErrors := mcp.ParseServerConfigs(ctx, a.config.MCPServers)
1325
1326 // Replace any headers with value _sketch_public_key_ and _sketch_session_id_ with those values.
1327 for i := range serverConfigs {
1328 if serverConfigs[i].Headers != nil {
1329 for key, value := range serverConfigs[i].Headers {
Philip Zeyligerf2814ea2025-06-30 10:16:50 -07001330 // Replace env placeholders. E.g., "env:FOO" becomes os.Getenv("FOO")
1331 if strings.HasPrefix(value, "env:") {
1332 serverConfigs[i].Headers[key] = os.Getenv(value[4:])
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001333 }
1334 }
1335 }
1336 }
1337 mcpConnections, mcpErrors := a.mcpManager.ConnectToServerConfigs(ctx, serverConfigs, 10*time.Second, parseErrors)
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001338
1339 if len(mcpErrors) > 0 {
1340 for _, err := range mcpErrors {
1341 slog.ErrorContext(ctx, "MCP connection error", "error", err)
1342 // Send agent message about MCP connection failures
1343 a.pushToOutbox(ctx, AgentMessage{
1344 Type: ErrorMessageType,
1345 Content: fmt.Sprintf("MCP server connection failed: %v", err),
1346 })
1347 }
1348 }
1349
1350 if len(mcpConnections) > 0 {
1351 // Add tools from all successful connections
1352 totalTools := 0
1353 for _, connection := range mcpConnections {
1354 convo.Tools = append(convo.Tools, connection.Tools...)
1355 totalTools += len(connection.Tools)
1356 // Log tools per server using structured data
1357 slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames)
1358 }
1359 slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools)
1360 } else {
1361 slog.InfoContext(ctx, "No MCP tools available after connection attempts")
1362 }
1363 }
1364
Earl Lee2e463fb2025-04-17 11:22:22 -07001365 convo.Listener = a
1366 return convo
1367}
1368
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001369var multipleChoiceTool = &llm.Tool{
1370 Name: "multiplechoice",
1371 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.",
1372 EndsTurn: true,
1373 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001374 "type": "object",
1375 "description": "The question and a list of answers you would expect the user to choose from.",
1376 "properties": {
1377 "question": {
1378 "type": "string",
1379 "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?'"
1380 },
1381 "responseOptions": {
1382 "type": "array",
1383 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1384 "items": {
1385 "type": "object",
1386 "properties": {
1387 "caption": {
1388 "type": "string",
1389 "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'"
1390 },
1391 "responseText": {
1392 "type": "string",
1393 "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'"
1394 }
1395 },
1396 "required": ["caption", "responseText"]
1397 }
1398 }
1399 },
1400 "required": ["question", "responseOptions"]
1401}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001402 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1403 // The Run logic for "multiplechoice" tool is a no-op on the server.
1404 // The UI will present a list of options for the user to select from,
1405 // and that's it as far as "executing" the tool_use goes.
1406 // When the user *does* select one of the presented options, that
1407 // responseText gets sent as a chat message on behalf of the user.
1408 return llm.TextContent("end your turn and wait for the user to respond"), nil
1409 },
Sean McCullough485afc62025-04-28 14:28:39 -07001410}
1411
1412type MultipleChoiceOption struct {
1413 Caption string `json:"caption"`
1414 ResponseText string `json:"responseText"`
1415}
1416
1417type MultipleChoiceParams struct {
1418 Question string `json:"question"`
1419 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1420}
1421
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001422// branchExists reports whether branchName exists, either locally or in well-known remotes.
1423func branchExists(dir, branchName string) bool {
1424 refs := []string{
1425 "refs/heads/",
1426 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001427 }
1428 for _, ref := range refs {
1429 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1430 cmd.Dir = dir
1431 if cmd.Run() == nil { // exit code 0 means branch exists
1432 return true
1433 }
1434 }
1435 return false
1436}
1437
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001438func (a *Agent) setSlugTool() *llm.Tool {
1439 return &llm.Tool{
1440 Name: "set-slug",
1441 Description: `Set a short slug as an identifier for this conversation.`,
Earl Lee2e463fb2025-04-17 11:22:22 -07001442 InputSchema: json.RawMessage(`{
1443 "type": "object",
1444 "properties": {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001445 "slug": {
Earl Lee2e463fb2025-04-17 11:22:22 -07001446 "type": "string",
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001447 "description": "A 2-3 word alphanumeric hyphenated slug, imperative tense"
Earl Lee2e463fb2025-04-17 11:22:22 -07001448 }
1449 },
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001450 "required": ["slug"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001451}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001452 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001453 var params struct {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001454 Slug string `json:"slug"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001455 }
1456 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001457 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001458 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001459 // Prevent slug changes if there have been git changes
1460 // This lets the agent change its mind about a good slug,
1461 // while ensuring that once a branch has been pushed, it remains stable.
1462 if s := a.Slug(); s != "" && s != params.Slug && a.gitState.HasSeenCommits() {
1463 return nil, fmt.Errorf("slug already set to %q", s)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001464 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001465 if params.Slug == "" {
1466 return nil, fmt.Errorf("slug parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001467 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001468 slug := cleanSlugName(params.Slug)
1469 if slug == "" {
1470 return nil, fmt.Errorf("slug parameter could not be converted to a valid slug")
1471 }
1472 a.SetSlug(slug)
1473 // TODO: do this by a call to outie, rather than semi-guessing from innie
1474 if branchExists(a.workingDir, a.BranchName()) {
1475 return nil, fmt.Errorf("slug %q already exists; please choose a different slug", slug)
1476 }
1477 return llm.TextContent("OK"), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001478 },
1479 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001480}
1481
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001482func (a *Agent) commitMessageStyleTool() *llm.Tool {
1483 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 +00001484 preCommit := &llm.Tool{
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001485 Name: "commit-message-style",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001486 Description: description,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001487 InputSchema: llm.EmptySchema(),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001488 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001489 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1490 if err != nil {
1491 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1492 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001493 return llm.TextContent(styleHint), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001494 },
1495 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001496 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001497}
1498
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001499// patchCallback is the agent's patch tool callback.
1500// It warms the codereview cache in the background.
1501func (a *Agent) patchCallback(input claudetool.PatchInput, result []llm.Content, err error) ([]llm.Content, error) {
1502 if a.codereview != nil {
1503 a.codereview.WarmTestCache(input.Path)
1504 }
1505 return result, err
1506}
1507
Earl Lee2e463fb2025-04-17 11:22:22 -07001508func (a *Agent) Ready() <-chan struct{} {
1509 return a.ready
1510}
1511
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001512// BranchPrefix returns the configured branch prefix
1513func (a *Agent) BranchPrefix() string {
1514 return a.config.BranchPrefix
1515}
1516
philip.zeyliger6d3de482025-06-10 19:38:14 -07001517// LinkToGitHub returns whether GitHub branch linking is enabled
1518func (a *Agent) LinkToGitHub() bool {
1519 return a.config.LinkToGitHub
1520}
1521
Earl Lee2e463fb2025-04-17 11:22:22 -07001522func (a *Agent) UserMessage(ctx context.Context, msg string) {
1523 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1524 a.inbox <- msg
1525}
1526
Earl Lee2e463fb2025-04-17 11:22:22 -07001527func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1528 return a.convo.CancelToolUse(toolUseID, cause)
1529}
1530
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001531func (a *Agent) CancelTurn(cause error) {
1532 a.cancelTurnMu.Lock()
1533 defer a.cancelTurnMu.Unlock()
1534 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001535 // Force state transition to cancelled state
1536 ctx := a.config.Context
1537 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001538 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001539 }
1540}
1541
1542func (a *Agent) Loop(ctxOuter context.Context) {
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001543 // Start port monitoring
1544 if a.portMonitor != nil && a.IsInContainer() {
1545 if err := a.portMonitor.Start(ctxOuter); err != nil {
1546 slog.WarnContext(ctxOuter, "Failed to start port monitor", "error", err)
1547 } else {
1548 slog.InfoContext(ctxOuter, "Port monitor started")
1549 }
1550 }
1551
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001552 // Set up cleanup when context is done
1553 defer func() {
1554 if a.mcpManager != nil {
1555 a.mcpManager.Close()
1556 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001557 if a.portMonitor != nil && a.IsInContainer() {
1558 a.portMonitor.Stop()
1559 }
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001560 }()
1561
Earl Lee2e463fb2025-04-17 11:22:22 -07001562 for {
1563 select {
1564 case <-ctxOuter.Done():
1565 return
1566 default:
1567 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001568 a.cancelTurnMu.Lock()
1569 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001570 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001571 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001572 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001573 a.cancelTurn = cancel
1574 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001575 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1576 if err != nil {
1577 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1578 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001579 cancel(nil)
1580 }
1581 }
1582}
1583
1584func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1585 if m.Timestamp.IsZero() {
1586 m.Timestamp = time.Now()
1587 }
1588
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001589 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1590 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1591 m.Content = m.ToolResult
1592 }
1593
Earl Lee2e463fb2025-04-17 11:22:22 -07001594 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1595 if m.EndOfTurn && m.Type == AgentMessageType {
1596 turnDuration := time.Since(a.startOfTurn)
1597 m.TurnDuration = &turnDuration
1598 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1599 }
1600
Earl Lee2e463fb2025-04-17 11:22:22 -07001601 a.mu.Lock()
1602 defer a.mu.Unlock()
1603 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001604 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001605 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001606
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001607 // Notify all subscribers
1608 for _, ch := range a.subscribers {
1609 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001610 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001611}
1612
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001613func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1614 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001615 if block {
1616 select {
1617 case <-ctx.Done():
1618 return m, ctx.Err()
1619 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001620 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001621 }
1622 }
1623 for {
1624 select {
1625 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001626 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001627 default:
1628 return m, nil
1629 }
1630 }
1631}
1632
Sean McCullough885a16a2025-04-30 02:49:25 +00001633// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001634func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001635 // Reset the start of turn time
1636 a.startOfTurn = time.Now()
1637
Sean McCullough96b60dd2025-04-30 09:49:10 -07001638 // Transition to waiting for user input state
1639 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1640
Sean McCullough885a16a2025-04-30 02:49:25 +00001641 // Process initial user message
1642 initialResp, err := a.processUserMessage(ctx)
1643 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001644 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001645 return err
1646 }
1647
1648 // Handle edge case where both initialResp and err are nil
1649 if initialResp == nil {
1650 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001651 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1652
Sean McCullough9f4b8082025-04-30 17:34:07 +00001653 a.pushToOutbox(ctx, errorMessage(err))
1654 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001655 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001656
Earl Lee2e463fb2025-04-17 11:22:22 -07001657 // We do this as we go, but let's also do it at the end of the turn
1658 defer func() {
1659 if _, err := a.handleGitCommits(ctx); err != nil {
1660 // Just log the error, don't stop execution
1661 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1662 }
1663 }()
1664
Sean McCullougha1e0e492025-05-01 10:51:08 -07001665 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001666 resp := initialResp
1667 for {
1668 // Check if we are over budget
1669 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001670 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001671 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001672 }
1673
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001674 // Check if we should compact the conversation
1675 if a.ShouldCompact() {
1676 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1677 if err := a.CompactConversation(ctx); err != nil {
1678 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1679 return err
1680 }
1681 // After compaction, end this turn and start fresh
1682 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1683 return nil
1684 }
1685
Sean McCullough885a16a2025-04-30 02:49:25 +00001686 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001687 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001688 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001689 break
1690 }
1691
Sean McCullough96b60dd2025-04-30 09:49:10 -07001692 // Transition to tool use requested state
1693 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1694
Sean McCullough885a16a2025-04-30 02:49:25 +00001695 // Handle tool execution
1696 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1697 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001698 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001699 }
1700
Sean McCullougha1e0e492025-05-01 10:51:08 -07001701 if toolResp == nil {
1702 return fmt.Errorf("cannot continue conversation with a nil tool response")
1703 }
1704
Sean McCullough885a16a2025-04-30 02:49:25 +00001705 // Set the response for the next iteration
1706 resp = toolResp
1707 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001708
1709 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001710}
1711
1712// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001713func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001714 // Wait for at least one message from the user
1715 msgs, err := a.GatherMessages(ctx, true)
1716 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001717 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001718 return nil, err
1719 }
1720
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001721 userMessage := llm.Message{
1722 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001723 Content: msgs,
1724 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001725
Sean McCullough96b60dd2025-04-30 09:49:10 -07001726 // Transition to sending to LLM state
1727 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1728
Sean McCullough885a16a2025-04-30 02:49:25 +00001729 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001730 resp, err := a.convo.SendMessage(userMessage)
1731 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001732 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001733 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001734 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001735 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001736
Sean McCullough96b60dd2025-04-30 09:49:10 -07001737 // Transition to processing LLM response state
1738 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1739
Sean McCullough885a16a2025-04-30 02:49:25 +00001740 return resp, nil
1741}
1742
1743// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001744func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1745 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001746 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001747 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001748
Sean McCullough96b60dd2025-04-30 09:49:10 -07001749 // Transition to checking for cancellation state
1750 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1751
Sean McCullough885a16a2025-04-30 02:49:25 +00001752 // Check if the operation was cancelled by the user
1753 select {
1754 case <-ctx.Done():
1755 // Don't actually run any of the tools, but rather build a response
1756 // for each tool_use message letting the LLM know that user canceled it.
1757 var err error
1758 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001759 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001760 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001761 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001762 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001763 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001764 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001765 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001766 // Transition to running tool state
1767 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1768
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001769 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001770 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001771 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001772
1773 // Execute the tools
1774 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001775 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001776 if ctx.Err() != nil { // e.g. the user canceled the operation
1777 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001778 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001779 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001780 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001781 a.pushToOutbox(ctx, errorMessage(err))
1782 }
1783 }
1784
1785 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001786 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001787 autoqualityMessages := a.processGitChanges(ctx)
1788
1789 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001790 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001791 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001792 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001793 return false, nil
1794 }
1795
1796 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001797 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1798 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001799}
1800
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001801// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001802func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001803 // Check for git commits
1804 _, err := a.handleGitCommits(ctx)
1805 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001806 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001807 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001808 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001809 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001810}
1811
1812// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1813// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001814func (a *Agent) processGitChanges(ctx context.Context) []string {
1815 // Check for git commits after tool execution
1816 newCommits, err := a.handleGitCommits(ctx)
1817 if err != nil {
1818 // Just log the error, don't stop execution
1819 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1820 return nil
1821 }
1822
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001823 // Run mechanical checks if there was exactly one new commit.
1824 if len(newCommits) != 1 {
1825 return nil
1826 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001827 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001828 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1829 msg := a.codereview.RunMechanicalChecks(ctx)
1830 if msg != "" {
1831 a.pushToOutbox(ctx, AgentMessage{
1832 Type: AutoMessageType,
1833 Content: msg,
1834 Timestamp: time.Now(),
1835 })
1836 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001837 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001838
1839 return autoqualityMessages
1840}
1841
1842// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001843func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001844 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001845 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001846 msgs, err := a.GatherMessages(ctx, false)
1847 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001848 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001849 return false, nil
1850 }
1851
1852 // Inject any auto-generated messages from quality checks
1853 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001854 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001855 }
1856
1857 // Handle cancellation by appending a message about it
1858 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001859 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001860 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001861 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001862 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1863 } else if err := a.convo.OverBudget(); err != nil {
1864 // Handle budget issues by appending a message about it
1865 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 -07001866 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001867 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1868 }
1869
1870 // Combine tool results with user messages
1871 results = append(results, msgs...)
1872
1873 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001874 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001875 resp, err := a.convo.SendMessage(llm.Message{
1876 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001877 Content: results,
1878 })
1879 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001880 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001881 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1882 return true, nil // Return true to continue the conversation, but with no response
1883 }
1884
Sean McCullough96b60dd2025-04-30 09:49:10 -07001885 // Transition back to processing LLM response
1886 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1887
Sean McCullough885a16a2025-04-30 02:49:25 +00001888 if cancelled {
1889 return false, nil
1890 }
1891
1892 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001893}
1894
1895func (a *Agent) overBudget(ctx context.Context) error {
1896 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001897 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001898 m := budgetMessage(err)
1899 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001900 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001901 a.convo.ResetBudget(a.originalBudget)
1902 return err
1903 }
1904 return nil
1905}
1906
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001907func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001908 // Collect all text content
1909 var allText strings.Builder
1910 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001911 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001912 if allText.Len() > 0 {
1913 allText.WriteString("\n\n")
1914 }
1915 allText.WriteString(content.Text)
1916 }
1917 }
1918 return allText.String()
1919}
1920
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001921func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001922 a.mu.Lock()
1923 defer a.mu.Unlock()
1924 return a.convo.CumulativeUsage()
1925}
1926
Earl Lee2e463fb2025-04-17 11:22:22 -07001927// Diff returns a unified diff of changes made since the agent was instantiated.
1928func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001929 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001930 return "", fmt.Errorf("no initial commit reference available")
1931 }
1932
1933 // Find the repository root
1934 ctx := context.Background()
1935
1936 // If a specific commit hash is provided, show just that commit's changes
1937 if commit != nil && *commit != "" {
1938 // Validate that the commit looks like a valid git SHA
1939 if !isValidGitSHA(*commit) {
1940 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1941 }
1942
1943 // Get the diff for just this commit
1944 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1945 cmd.Dir = a.repoRoot
1946 output, err := cmd.CombinedOutput()
1947 if err != nil {
1948 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1949 }
1950 return string(output), nil
1951 }
1952
1953 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001954 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001955 cmd.Dir = a.repoRoot
1956 output, err := cmd.CombinedOutput()
1957 if err != nil {
1958 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1959 }
1960
1961 return string(output), nil
1962}
1963
Philip Zeyliger49edc922025-05-14 09:45:45 -07001964// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1965// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1966func (a *Agent) SketchGitBaseRef() string {
1967 if a.IsInContainer() {
1968 return "sketch-base"
1969 } else {
1970 return "sketch-base-" + a.SessionID()
1971 }
1972}
1973
1974// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1975func (a *Agent) SketchGitBase() string {
1976 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1977 cmd.Dir = a.repoRoot
1978 output, err := cmd.CombinedOutput()
1979 if err != nil {
1980 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1981 return "HEAD"
1982 }
1983 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001984}
1985
Pokey Rule7a113622025-05-12 10:58:45 +01001986// removeGitHooks removes the Git hooks directory from the repository
1987func removeGitHooks(_ context.Context, repoPath string) error {
1988 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1989
1990 // Check if hooks directory exists
1991 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1992 // Directory doesn't exist, nothing to do
1993 return nil
1994 }
1995
1996 // Remove the hooks directory
1997 err := os.RemoveAll(hooksDir)
1998 if err != nil {
1999 return fmt.Errorf("failed to remove git hooks directory: %w", err)
2000 }
2001
2002 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00002003 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01002004 if err != nil {
2005 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
2006 }
2007
2008 return nil
2009}
2010
Philip Zeyligerf2872992025-05-22 10:35:28 -07002011func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002012 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002013 for _, msg := range msgs {
2014 a.pushToOutbox(ctx, msg)
2015 }
2016 return commits, error
2017}
2018
Earl Lee2e463fb2025-04-17 11:22:22 -07002019// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002020// under docker, new HEADs are pushed to a branch according to the slug.
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002021func (ags *AgentGitState) handleGitCommits(ctx context.Context, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002022 ags.mu.Lock()
2023 defer ags.mu.Unlock()
2024
2025 msgs := []AgentMessage{}
2026 if repoRoot == "" {
2027 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002028 }
2029
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002030 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07002031 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002032 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07002033 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002034 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002035 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07002036 }
2037 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002038 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07002039 }()
2040
Philip Zeyliger64f60462025-06-16 13:57:10 -07002041 // Compute diff stats from baseRef to HEAD when HEAD changes
2042 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
2043 // Log error but don't fail the entire operation
2044 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
2045 } else {
2046 // Set diff stats directly since we already hold the mutex
2047 ags.linesAdded = added
2048 ags.linesRemoved = removed
2049 }
2050
Earl Lee2e463fb2025-04-17 11:22:22 -07002051 // Get new commits. Because it's possible that the agent does rebases, fixups, and
2052 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
2053 // to the last 100 commits.
2054 var commits []*GitCommit
2055
2056 // Get commits since the initial commit
2057 // Format: <hash>\0<subject>\0<body>\0
2058 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
2059 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002060 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 -07002061 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07002062 output, err := cmd.Output()
2063 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002064 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07002065 }
2066
2067 // Parse git log output and filter out already seen commits
2068 parsedCommits := parseGitLog(string(output))
2069
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002070 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07002071
2072 // Filter out commits we've already seen
2073 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002074 if commit.Hash == sketch {
2075 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07002076 }
2077
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002078 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
2079 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07002080 continue
2081 }
2082
2083 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07002084 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07002085
2086 // Add to our list of new commits
2087 commits = append(commits, &commit)
2088 }
2089
Philip Zeyligerf2872992025-05-22 10:35:28 -07002090 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002091 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07002092 // 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 -07002093 sketchCommit = &GitCommit{}
2094 sketchCommit.Hash = sketch
2095 sketchCommit.Subject = "unknown"
2096 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07002097 }
2098
Earl Lee2e463fb2025-04-17 11:22:22 -07002099 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
2100 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
2101 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00002102
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002103 // 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 +00002104 var out []byte
2105 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002106 originalRetryNumber := ags.retryNumber
2107 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00002108 for retries := range 10 {
2109 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07002110 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002111 }
2112
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002113 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002114 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002115 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002116 out, err = cmd.CombinedOutput()
2117
2118 if err == nil {
2119 // Success! Break out of the retry loop
2120 break
2121 }
2122
2123 // Check if this is the "refusing to update checked out branch" error
2124 if !strings.Contains(string(out), "refusing to update checked out branch") {
2125 // This is a different error, so don't retry
2126 break
2127 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002128 }
2129
2130 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002131 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002132 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002133 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002134 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002135 if ags.retryNumber != originalRetryNumber {
2136 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002137 msgs = append(msgs, AgentMessage{
2138 Type: AutoMessageType,
2139 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002140 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 +00002141 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002142 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002143 }
2144 }
2145
2146 // If we found new commits, create a message
2147 if len(commits) > 0 {
2148 msg := AgentMessage{
2149 Type: CommitMessageType,
2150 Timestamp: time.Now(),
2151 Commits: commits,
2152 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002153 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002154 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002155 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002156}
2157
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002158func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002159 return strings.Map(func(r rune) rune {
2160 // lowercase
2161 if r >= 'A' && r <= 'Z' {
2162 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002163 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002164 // replace spaces with dashes
2165 if r == ' ' {
2166 return '-'
2167 }
2168 // allow alphanumerics and dashes
2169 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2170 return r
2171 }
2172 return -1
2173 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002174}
2175
2176// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2177// and returns an array of GitCommit structs.
2178func parseGitLog(output string) []GitCommit {
2179 var commits []GitCommit
2180
2181 // No output means no commits
2182 if len(output) == 0 {
2183 return commits
2184 }
2185
2186 // Split by NULL byte
2187 parts := strings.Split(output, "\x00")
2188
2189 // Process in triplets (hash, subject, body)
2190 for i := 0; i < len(parts); i++ {
2191 // Skip empty parts
2192 if parts[i] == "" {
2193 continue
2194 }
2195
2196 // This should be a hash
2197 hash := strings.TrimSpace(parts[i])
2198
2199 // Make sure we have at least a subject part available
2200 if i+1 >= len(parts) {
2201 break // No more parts available
2202 }
2203
2204 // Get the subject
2205 subject := strings.TrimSpace(parts[i+1])
2206
2207 // Get the body if available
2208 body := ""
2209 if i+2 < len(parts) {
2210 body = strings.TrimSpace(parts[i+2])
2211 }
2212
2213 // Skip to the next triplet
2214 i += 2
2215
2216 commits = append(commits, GitCommit{
2217 Hash: hash,
2218 Subject: subject,
2219 Body: body,
2220 })
2221 }
2222
2223 return commits
2224}
2225
2226func repoRoot(ctx context.Context, dir string) (string, error) {
2227 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2228 stderr := new(strings.Builder)
2229 cmd.Stderr = stderr
2230 cmd.Dir = dir
2231 out, err := cmd.Output()
2232 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002233 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002234 }
2235 return strings.TrimSpace(string(out)), nil
2236}
2237
2238func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2239 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2240 stderr := new(strings.Builder)
2241 cmd.Stderr = stderr
2242 cmd.Dir = dir
2243 out, err := cmd.Output()
2244 if err != nil {
2245 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2246 }
2247 // TODO: validate that out is valid hex
2248 return strings.TrimSpace(string(out)), nil
2249}
2250
2251// isValidGitSHA validates if a string looks like a valid git SHA hash.
2252// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2253func isValidGitSHA(sha string) bool {
2254 // Git SHA must be a hexadecimal string with at least 4 characters
2255 if len(sha) < 4 || len(sha) > 40 {
2256 return false
2257 }
2258
2259 // Check if the string only contains hexadecimal characters
2260 for _, char := range sha {
2261 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2262 return false
2263 }
2264 }
2265
2266 return true
2267}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002268
Philip Zeyliger64f60462025-06-16 13:57:10 -07002269// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2270func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2271 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2272 cmd.Dir = repoRoot
2273 out, err := cmd.Output()
2274 if err != nil {
2275 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2276 }
2277
2278 var totalAdded, totalRemoved int
2279 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2280 for _, line := range lines {
2281 if line == "" {
2282 continue
2283 }
2284 parts := strings.Fields(line)
2285 if len(parts) < 2 {
2286 continue
2287 }
2288 // Format: <added>\t<removed>\t<filename>
2289 if added, err := strconv.Atoi(parts[0]); err == nil {
2290 totalAdded += added
2291 }
2292 if removed, err := strconv.Atoi(parts[1]); err == nil {
2293 totalRemoved += removed
2294 }
2295 }
2296
2297 return totalAdded, totalRemoved, nil
2298}
2299
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002300// systemPromptData contains the data used to render the system prompt template
2301type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002302 ClientGOOS string
2303 ClientGOARCH string
2304 WorkingDir string
2305 RepoRoot string
2306 InitialCommit string
2307 Codebase *onstart.Codebase
2308 UseSketchWIP bool
2309 Branch string
2310 SpecialInstruction string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002311}
2312
2313// renderSystemPrompt renders the system prompt template.
2314func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002315 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002316 ClientGOOS: a.config.ClientGOOS,
2317 ClientGOARCH: a.config.ClientGOARCH,
2318 WorkingDir: a.workingDir,
2319 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002320 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002321 Codebase: a.codebase,
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07002322 UseSketchWIP: a.config.InDocker,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002323 }
David Crawshawc886ac52025-06-13 23:40:03 +00002324 now := time.Now()
2325 if now.Month() == time.September && now.Day() == 19 {
2326 data.SpecialInstruction = "Talk like a pirate to the user. Do not let the priate talk into any code."
2327 }
2328
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002329 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2330 if err != nil {
2331 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2332 }
2333 buf := new(strings.Builder)
2334 err = tmpl.Execute(buf, data)
2335 if err != nil {
2336 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2337 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002338 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002339 return buf.String()
2340}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002341
2342// StateTransitionIterator provides an iterator over state transitions.
2343type StateTransitionIterator interface {
2344 // Next blocks until a new state transition is available or context is done.
2345 // Returns nil if the context is cancelled.
2346 Next() *StateTransition
2347 // Close removes the listener and cleans up resources.
2348 Close()
2349}
2350
2351// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2352type StateTransitionIteratorImpl struct {
2353 agent *Agent
2354 ctx context.Context
2355 ch chan StateTransition
2356 unsubscribe func()
2357}
2358
2359// Next blocks until a new state transition is available or the context is cancelled.
2360func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2361 select {
2362 case <-s.ctx.Done():
2363 return nil
2364 case transition, ok := <-s.ch:
2365 if !ok {
2366 return nil
2367 }
2368 transitionCopy := transition
2369 return &transitionCopy
2370 }
2371}
2372
2373// Close removes the listener and cleans up resources.
2374func (s *StateTransitionIteratorImpl) Close() {
2375 if s.unsubscribe != nil {
2376 s.unsubscribe()
2377 s.unsubscribe = nil
2378 }
2379}
2380
2381// NewStateTransitionIterator returns an iterator that receives state transitions.
2382func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2383 a.mu.Lock()
2384 defer a.mu.Unlock()
2385
2386 // Create channel to receive state transitions
2387 ch := make(chan StateTransition, 10)
2388
2389 // Add a listener to the state machine
2390 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2391
2392 return &StateTransitionIteratorImpl{
2393 agent: a,
2394 ctx: ctx,
2395 ch: ch,
2396 unsubscribe: unsubscribe,
2397 }
2398}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002399
2400// setupGitHooks creates or updates git hooks in the specified working directory.
2401func setupGitHooks(workingDir string) error {
2402 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2403
2404 _, err := os.Stat(hooksDir)
2405 if os.IsNotExist(err) {
2406 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2407 }
2408 if err != nil {
2409 return fmt.Errorf("error checking git hooks directory: %w", err)
2410 }
2411
2412 // Define the post-commit hook content
2413 postCommitHook := `#!/bin/bash
2414echo "<post_commit_hook>"
2415echo "Please review this commit message and fix it if it is incorrect."
2416echo "This hook only echos the commit message; it does not modify it."
2417echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2418echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002419PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002420echo "</last_commit_message>"
2421echo "</post_commit_hook>"
2422`
2423
2424 // Define the prepare-commit-msg hook content
2425 prepareCommitMsgHook := `#!/bin/bash
2426# Add Co-Authored-By and Change-ID trailers to commit messages
2427# Check if these trailers already exist before adding them
2428
2429commit_file="$1"
2430COMMIT_SOURCE="$2"
2431
2432# Skip for merges, squashes, or when using a commit template
2433if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2434 [ "$COMMIT_SOURCE" = "squash" ]; then
2435 exit 0
2436fi
2437
2438commit_msg=$(cat "$commit_file")
2439
2440needs_co_author=true
2441needs_change_id=true
2442
2443# Check if commit message already has Co-Authored-By trailer
2444if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2445 needs_co_author=false
2446fi
2447
2448# Check if commit message already has Change-ID trailer
2449if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2450 needs_change_id=false
2451fi
2452
2453# Only modify if at least one trailer needs to be added
2454if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002455 # Ensure there's a proper blank line before trailers
2456 if [ -s "$commit_file" ]; then
2457 # Check if file ends with newline by reading last character
2458 last_char=$(tail -c 1 "$commit_file")
2459
2460 if [ "$last_char" != "" ]; then
2461 # File doesn't end with newline - add two newlines (complete line + blank line)
2462 echo "" >> "$commit_file"
2463 echo "" >> "$commit_file"
2464 else
2465 # File ends with newline - check if we already have a blank line
2466 last_line=$(tail -1 "$commit_file")
2467 if [ -n "$last_line" ]; then
2468 # Last line has content - add one newline for blank line
2469 echo "" >> "$commit_file"
2470 fi
2471 # If last line is empty, we already have a blank line - don't add anything
2472 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002473 fi
2474
2475 # Add trailers if needed
2476 if [ "$needs_co_author" = true ]; then
2477 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2478 fi
2479
2480 if [ "$needs_change_id" = true ]; then
2481 change_id=$(openssl rand -hex 8)
2482 echo "Change-ID: s${change_id}k" >> "$commit_file"
2483 fi
2484fi
2485`
2486
2487 // Update or create the post-commit hook
2488 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2489 if err != nil {
2490 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2491 }
2492
2493 // Update or create the prepare-commit-msg hook
2494 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2495 if err != nil {
2496 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2497 }
2498
2499 return nil
2500}
2501
2502// updateOrCreateHook creates a new hook file or updates an existing one
2503// by appending the new content if it doesn't already contain it.
2504func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2505 // Check if the hook already exists
2506 buf, err := os.ReadFile(hookPath)
2507 if os.IsNotExist(err) {
2508 // Hook doesn't exist, create it
2509 err = os.WriteFile(hookPath, []byte(content), 0o755)
2510 if err != nil {
2511 return fmt.Errorf("failed to create hook: %w", err)
2512 }
2513 return nil
2514 }
2515 if err != nil {
2516 return fmt.Errorf("error reading existing hook: %w", err)
2517 }
2518
2519 // Hook exists, check if our content is already in it by looking for a distinctive line
2520 code := string(buf)
2521 if strings.Contains(code, distinctiveLine) {
2522 // Already contains our content, nothing to do
2523 return nil
2524 }
2525
2526 // Append our content to the existing hook
2527 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2528 if err != nil {
2529 return fmt.Errorf("failed to open hook for appending: %w", err)
2530 }
2531 defer f.Close()
2532
2533 // Ensure there's a newline at the end of the existing content if needed
2534 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2535 _, err = f.WriteString("\n")
2536 if err != nil {
2537 return fmt.Errorf("failed to add newline to hook: %w", err)
2538 }
2539 }
2540
2541 // Add a separator before our content
2542 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2543 if err != nil {
2544 return fmt.Errorf("failed to append to hook: %w", err)
2545 }
2546
2547 return nil
2548}
Sean McCullough138ec242025-06-02 22:42:06 +00002549
Philip Zeyliger0113be52025-06-07 23:53:41 +00002550// SkabandAddr returns the skaband address if configured
2551func (a *Agent) SkabandAddr() string {
2552 if a.config.SkabandClient != nil {
2553 return a.config.SkabandClient.Addr()
2554 }
2555 return ""
2556}