blob: ee6d8d7e0804c4aec4cdd5d67a2951554548dbd7 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package loop
2
3import (
4 "context"
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07005 _ "embed"
Earl Lee2e463fb2025-04-17 11:22:22 -07006 "encoding/json"
7 "fmt"
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +00008 "io"
Earl Lee2e463fb2025-04-17 11:22:22 -07009 "log/slog"
10 "net/http"
11 "os"
12 "os/exec"
Pokey Rule7a113622025-05-12 10:58:45 +010013 "path/filepath"
Earl Lee2e463fb2025-04-17 11:22:22 -070014 "runtime/debug"
15 "slices"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070016 "strconv"
Earl Lee2e463fb2025-04-17 11:22:22 -070017 "strings"
18 "sync"
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +000019 "text/template"
Earl Lee2e463fb2025-04-17 11:22:22 -070020 "time"
21
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000022 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070023 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000024 "sketch.dev/claudetool/bashkit"
Autoformatter4962f152025-05-06 17:24:20 +000025 "sketch.dev/claudetool/browse"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000026 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydera997be62025-05-07 22:52:46 +000027 "sketch.dev/claudetool/onstart"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070028 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070029 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070030 "sketch.dev/llm/conversation"
Philip Zeyliger194bfa82025-06-24 06:03:06 -070031 "sketch.dev/mcp"
Philip Zeyligerc17ffe32025-06-05 19:49:13 -070032 "sketch.dev/skabandclient"
Earl Lee2e463fb2025-04-17 11:22:22 -070033)
34
35const (
36 userCancelMessage = "user requested agent to stop handling responses"
37)
38
Philip Zeyligerb7c58752025-05-01 10:10:17 -070039type MessageIterator interface {
40 // Next blocks until the next message is available. It may
41 // return nil if the underlying iterator context is done.
42 Next() *AgentMessage
43 Close()
44}
45
Earl Lee2e463fb2025-04-17 11:22:22 -070046type CodingAgent interface {
47 // Init initializes an agent inside a docker container.
48 Init(AgentInit) error
49
50 // Ready returns a channel closed after Init successfully called.
51 Ready() <-chan struct{}
52
53 // URL reports the HTTP URL of this agent.
54 URL() string
55
56 // UserMessage enqueues a message to the agent and returns immediately.
57 UserMessage(ctx context.Context, msg string)
58
Philip Zeyligerb7c58752025-05-01 10:10:17 -070059 // Returns an iterator that finishes when the context is done and
60 // starts with the given message index.
61 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070062
Philip Zeyligereab12de2025-05-14 02:35:53 +000063 // Returns an iterator that notifies of state transitions until the context is done.
64 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
65
Earl Lee2e463fb2025-04-17 11:22:22 -070066 // Loop begins the agent loop returns only when ctx is cancelled.
67 Loop(ctx context.Context)
68
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000069 // BranchPrefix returns the configured branch prefix
70 BranchPrefix() string
71
philip.zeyliger6d3de482025-06-10 19:38:14 -070072 // LinkToGitHub returns whether GitHub branch linking is enabled
73 LinkToGitHub() bool
74
Sean McCulloughedc88dc2025-04-30 02:55:01 +000075 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070076
77 CancelToolUse(toolUseID string, cause error) error
78
79 // Returns a subset of the agent's message history.
80 Messages(start int, end int) []AgentMessage
81
82 // Returns the current number of messages in the history
83 MessageCount() int
84
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070085 TotalUsage() conversation.CumulativeUsage
86 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070087
Earl Lee2e463fb2025-04-17 11:22:22 -070088 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000089 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070090
91 // Diff returns a unified diff of changes made since the agent was instantiated.
92 // If commit is non-nil, it shows the diff for just that specific commit.
93 Diff(commit *string) (string, error)
94
Philip Zeyliger49edc922025-05-14 09:45:45 -070095 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
96 // starts out as the commit where sketch started, but a user can move it if need
97 // be, for example in the case of a rebase. It is stored as a git tag.
98 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070099
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000100 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
101 // (Typically, this is "sketch-base")
102 SketchGitBaseRef() string
103
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700104 // Slug returns the slug identifier for this session.
105 Slug() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700106
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000107 // BranchName returns the git branch name for the conversation.
108 BranchName() string
109
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700110 // IncrementRetryNumber increments the retry number for branch naming conflicts.
111 IncrementRetryNumber()
112
Earl Lee2e463fb2025-04-17 11:22:22 -0700113 // OS returns the operating system of the client.
114 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000115
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000116 // SessionID returns the unique session identifier.
117 SessionID() string
118
philip.zeyliger8773e682025-06-11 21:36:21 -0700119 // SSHConnectionString returns the SSH connection string for the container.
120 SSHConnectionString() string
121
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000122 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700123 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000124
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000125 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
126 OutstandingLLMCallCount() int
127
128 // OutstandingToolCalls returns the names of outstanding tool calls.
129 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000130 OutsideOS() string
131 OutsideHostname() string
132 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000133 GitOrigin() string
Philip Zeyliger64f60462025-06-16 13:57:10 -0700134
135 // DiffStats returns the number of lines added and removed from sketch-base to HEAD
136 DiffStats() (int, int)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000137 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
138 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700139
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700140 // IsInContainer returns true if the agent is running in a container
141 IsInContainer() bool
142 // FirstMessageIndex returns the index of the first message in the current conversation
143 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700144
145 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700146 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
147 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700148
149 // CompactConversation compacts the current conversation by generating a summary
150 // and restarting the conversation with that summary as the initial context
151 CompactConversation(ctx context.Context) error
Sean McCullough138ec242025-06-02 22:42:06 +0000152 // GetPortMonitor returns the port monitor instance for accessing port events
153 GetPortMonitor() *PortMonitor
Philip Zeyliger0113be52025-06-07 23:53:41 +0000154 // SkabandAddr returns the skaband address if configured
155 SkabandAddr() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700156}
157
158type CodingAgentMessageType string
159
160const (
161 UserMessageType CodingAgentMessageType = "user"
162 AgentMessageType CodingAgentMessageType = "agent"
163 ErrorMessageType CodingAgentMessageType = "error"
164 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
165 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700166 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
167 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
168 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700169
170 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
171)
172
173type AgentMessage struct {
174 Type CodingAgentMessageType `json:"type"`
175 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
176 EndOfTurn bool `json:"end_of_turn"`
177
178 Content string `json:"content"`
179 ToolName string `json:"tool_name,omitempty"`
180 ToolInput string `json:"input,omitempty"`
181 ToolResult string `json:"tool_result,omitempty"`
182 ToolError bool `json:"tool_error,omitempty"`
183 ToolCallId string `json:"tool_call_id,omitempty"`
184
185 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
186 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
187
Sean McCulloughd9f13372025-04-21 15:08:49 -0700188 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
189 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
190
Earl Lee2e463fb2025-04-17 11:22:22 -0700191 // Commits is a list of git commits for a commit message
192 Commits []*GitCommit `json:"commits,omitempty"`
193
194 Timestamp time.Time `json:"timestamp"`
195 ConversationID string `json:"conversation_id"`
196 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700197 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700198
199 // Message timing information
200 StartTime *time.Time `json:"start_time,omitempty"`
201 EndTime *time.Time `json:"end_time,omitempty"`
202 Elapsed *time.Duration `json:"elapsed,omitempty"`
203
204 // Turn duration - the time taken for a complete agent turn
205 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
206
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000207 // HideOutput indicates that this message should not be rendered in the UI.
208 // This is useful for subconversations that generate output that shouldn't be shown to the user.
209 HideOutput bool `json:"hide_output,omitempty"`
210
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700211 // TodoContent contains the agent's todo file content when it has changed
212 TodoContent *string `json:"todo_content,omitempty"`
213
Earl Lee2e463fb2025-04-17 11:22:22 -0700214 Idx int `json:"idx"`
215}
216
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000217// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700218func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700219 if convo == nil {
220 m.ConversationID = ""
221 m.ParentConversationID = nil
222 return
223 }
224 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000225 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700226 if convo.Parent != nil {
227 m.ParentConversationID = &convo.Parent.ID
228 }
229}
230
Earl Lee2e463fb2025-04-17 11:22:22 -0700231// GitCommit represents a single git commit for a commit message
232type GitCommit struct {
233 Hash string `json:"hash"` // Full commit hash
234 Subject string `json:"subject"` // Commit subject line
235 Body string `json:"body"` // Full commit message body
236 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
237}
238
239// ToolCall represents a single tool call within an agent message
240type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700241 Name string `json:"name"`
242 Input string `json:"input"`
243 ToolCallId string `json:"tool_call_id"`
244 ResultMessage *AgentMessage `json:"result_message,omitempty"`
245 Args string `json:"args,omitempty"`
246 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700247}
248
249func (a *AgentMessage) Attr() slog.Attr {
250 var attrs []any = []any{
251 slog.String("type", string(a.Type)),
252 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700253 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700254 if a.EndOfTurn {
255 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
256 }
257 if a.Content != "" {
258 attrs = append(attrs, slog.String("content", a.Content))
259 }
260 if a.ToolName != "" {
261 attrs = append(attrs, slog.String("tool_name", a.ToolName))
262 }
263 if a.ToolInput != "" {
264 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
265 }
266 if a.Elapsed != nil {
267 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
268 }
269 if a.TurnDuration != nil {
270 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
271 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700272 if len(a.ToolResult) > 0 {
273 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700274 }
275 if a.ToolError {
276 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
277 }
278 if len(a.ToolCalls) > 0 {
279 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
280 for i, tc := range a.ToolCalls {
281 toolCallAttrs = append(toolCallAttrs, slog.Group(
282 fmt.Sprintf("tool_call_%d", i),
283 slog.String("name", tc.Name),
284 slog.String("input", tc.Input),
285 ))
286 }
287 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
288 }
289 if a.ConversationID != "" {
290 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
291 }
292 if a.ParentConversationID != nil {
293 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
294 }
295 if a.Usage != nil && !a.Usage.IsZero() {
296 attrs = append(attrs, a.Usage.Attr())
297 }
298 // TODO: timestamp, convo ids, idx?
299 return slog.Group("agent_message", attrs...)
300}
301
302func errorMessage(err error) AgentMessage {
303 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
304 if os.Getenv(("DEBUG")) == "1" {
305 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
306 }
307
308 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
309}
310
311func budgetMessage(err error) AgentMessage {
312 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
313}
314
315// ConvoInterface defines the interface for conversation interactions
316type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700317 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700318 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700319 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700320 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700321 SendMessage(message llm.Message) (*llm.Response, error)
322 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700323 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000324 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700325 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700326 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700327 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700328}
329
Philip Zeyligerf2872992025-05-22 10:35:28 -0700330// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700331// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700332// any time we notice we need to.
333type AgentGitState struct {
334 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700335 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700336 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000337 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700338 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700339 slug string // Human-readable session identifier
340 retryNumber int // Number to append when branch conflicts occur
Philip Zeyliger64f60462025-06-16 13:57:10 -0700341 linesAdded int // Lines added from sketch-base to HEAD
342 linesRemoved int // Lines removed from sketch-base to HEAD
Philip Zeyligerf2872992025-05-22 10:35:28 -0700343}
344
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700345func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700346 ags.mu.Lock()
347 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700348 if ags.slug != slug {
349 ags.retryNumber = 0
350 }
351 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700352}
353
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700354func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700355 ags.mu.Lock()
356 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700357 return ags.slug
358}
359
360func (ags *AgentGitState) IncrementRetryNumber() {
361 ags.mu.Lock()
362 defer ags.mu.Unlock()
363 ags.retryNumber++
364}
365
Philip Zeyliger64f60462025-06-16 13:57:10 -0700366func (ags *AgentGitState) DiffStats() (int, int) {
367 ags.mu.Lock()
368 defer ags.mu.Unlock()
369 return ags.linesAdded, ags.linesRemoved
370}
371
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700372// HasSeenCommits returns true if any commits have been processed
373func (ags *AgentGitState) HasSeenCommits() bool {
374 ags.mu.Lock()
375 defer ags.mu.Unlock()
376 return len(ags.seenCommits) > 0
377}
378
379func (ags *AgentGitState) RetryNumber() int {
380 ags.mu.Lock()
381 defer ags.mu.Unlock()
382 return ags.retryNumber
383}
384
385func (ags *AgentGitState) BranchName(prefix string) string {
386 ags.mu.Lock()
387 defer ags.mu.Unlock()
388 return ags.branchNameLocked(prefix)
389}
390
391func (ags *AgentGitState) branchNameLocked(prefix string) string {
392 if ags.slug == "" {
393 return ""
394 }
395 if ags.retryNumber == 0 {
396 return prefix + ags.slug
397 }
398 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700399}
400
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000401func (ags *AgentGitState) Upstream() string {
402 ags.mu.Lock()
403 defer ags.mu.Unlock()
404 return ags.upstream
405}
406
Earl Lee2e463fb2025-04-17 11:22:22 -0700407type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700408 convo ConvoInterface
409 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700410 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700411 workingDir string
412 repoRoot string // workingDir may be a subdir of repoRoot
413 url string
414 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000415 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700416 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000417 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700418 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700419 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000420 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700421 // State machine to track agent state
422 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000423 // Outside information
424 outsideHostname string
425 outsideOS string
426 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000427 // URL of the git remote 'origin' if it exists
428 gitOrigin string
Philip Zeyliger194bfa82025-06-24 06:03:06 -0700429 // MCP manager for handling MCP server connections
430 mcpManager *mcp.MCPManager
Earl Lee2e463fb2025-04-17 11:22:22 -0700431
432 // Time when the current turn started (reset at the beginning of InnerLoop)
433 startOfTurn time.Time
434
435 // Inbox - for messages from the user to the agent.
436 // sent on by UserMessage
437 // . e.g. when user types into the chat textarea
438 // read from by GatherMessages
439 inbox chan string
440
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000441 // protects cancelTurn
442 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700443 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000444 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700445
446 // protects following
447 mu sync.Mutex
448
449 // Stores all messages for this agent
450 history []AgentMessage
451
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700452 // Iterators add themselves here when they're ready to be notified of new messages.
453 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700454
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000455 // Track outstanding LLM call IDs
456 outstandingLLMCalls map[string]struct{}
457
458 // Track outstanding tool calls by ID with their names
459 outstandingToolCalls map[string]string
Sean McCullough364f7412025-06-02 00:55:44 +0000460
461 // Port monitoring
462 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700463}
464
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700465// NewIterator implements CodingAgent.
466func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
467 a.mu.Lock()
468 defer a.mu.Unlock()
469
470 return &MessageIteratorImpl{
471 agent: a,
472 ctx: ctx,
473 nextMessageIdx: nextMessageIdx,
474 ch: make(chan *AgentMessage, 100),
475 }
476}
477
478type MessageIteratorImpl struct {
479 agent *Agent
480 ctx context.Context
481 nextMessageIdx int
482 ch chan *AgentMessage
483 subscribed bool
484}
485
486func (m *MessageIteratorImpl) Close() {
487 m.agent.mu.Lock()
488 defer m.agent.mu.Unlock()
489 // Delete ourselves from the subscribers list
490 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
491 return x == m.ch
492 })
493 close(m.ch)
494}
495
496func (m *MessageIteratorImpl) Next() *AgentMessage {
497 // We avoid subscription at creation to let ourselves catch up to "current state"
498 // before subscribing.
499 if !m.subscribed {
500 m.agent.mu.Lock()
501 if m.nextMessageIdx < len(m.agent.history) {
502 msg := &m.agent.history[m.nextMessageIdx]
503 m.nextMessageIdx++
504 m.agent.mu.Unlock()
505 return msg
506 }
507 // The next message doesn't exist yet, so let's subscribe
508 m.agent.subscribers = append(m.agent.subscribers, m.ch)
509 m.subscribed = true
510 m.agent.mu.Unlock()
511 }
512
513 for {
514 select {
515 case <-m.ctx.Done():
516 m.agent.mu.Lock()
517 // Delete ourselves from the subscribers list
518 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
519 return x == m.ch
520 })
521 m.subscribed = false
522 m.agent.mu.Unlock()
523 return nil
524 case msg, ok := <-m.ch:
525 if !ok {
526 // Close may have been called
527 return nil
528 }
529 if msg.Idx == m.nextMessageIdx {
530 m.nextMessageIdx++
531 return msg
532 }
533 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
534 panic("out of order message")
535 }
536 }
537}
538
Sean McCulloughd9d45812025-04-30 16:53:41 -0700539// Assert that Agent satisfies the CodingAgent interface.
540var _ CodingAgent = &Agent{}
541
542// StateName implements CodingAgent.
543func (a *Agent) CurrentStateName() string {
544 if a.stateMachine == nil {
545 return ""
546 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000547 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700548}
549
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700550// CurrentTodoContent returns the current todo list data as JSON.
551// It returns an empty string if no todos exist.
552func (a *Agent) CurrentTodoContent() string {
553 todoPath := claudetool.TodoFilePath(a.config.SessionID)
554 content, err := os.ReadFile(todoPath)
555 if err != nil {
556 return ""
557 }
558 return string(content)
559}
560
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700561// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
562func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
563 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.
564
565IMPORTANT: 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.
566
567Please create a detailed summary that includes:
568
5691. **User's Request**: What did the user originally ask me to do? What was their goal?
570
5712. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
572
5733. **Key Technical Decisions**: What important technical choices were made during our work and why?
574
5754. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
576
5775. **Next Steps**: What still needs to be done to complete the user's request?
578
5796. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
580
581Focus 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.
582
583Reply with ONLY the summary content - no meta-commentary about creating the summary.`
584
585 userMessage := llm.UserStringMessage(msg)
586 // Use a subconversation with history to get the summary
587 // TODO: We don't have any tools here, so we should have enough tokens
588 // to capture a summary, but we may need to modify the history (e.g., remove
589 // TODO data) to save on some tokens.
590 convo := a.convo.SubConvoWithHistory()
591
592 // Modify the system prompt to provide context about the original task
593 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000594 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 -0700595
596Your 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.
597
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000598Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700599
600 resp, err := convo.SendMessage(userMessage)
601 if err != nil {
602 a.pushToOutbox(ctx, errorMessage(err))
603 return "", err
604 }
605 textContent := collectTextContent(resp)
606
607 // Restore original system prompt (though this subconvo will be discarded)
608 convo.SystemPrompt = originalSystemPrompt
609
610 return textContent, nil
611}
612
613// CompactConversation compacts the current conversation by generating a summary
614// and restarting the conversation with that summary as the initial context
615func (a *Agent) CompactConversation(ctx context.Context) error {
616 summary, err := a.generateConversationSummary(ctx)
617 if err != nil {
618 return fmt.Errorf("failed to generate conversation summary: %w", err)
619 }
620
621 a.mu.Lock()
622
623 // Get usage information before resetting conversation
624 lastUsage := a.convo.LastUsage()
625 contextWindow := a.config.Service.TokenContextWindow()
626 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
627
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000628 // Preserve cumulative usage across compaction
629 cumulativeUsage := a.convo.CumulativeUsage()
630
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700631 // Reset conversation state but keep all other state (git, working dir, etc.)
632 a.firstMessageIndex = len(a.history)
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000633 a.convo = a.initConvoWithUsage(&cumulativeUsage)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700634
635 a.mu.Unlock()
636
637 // Create informative compaction message with token details
638 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
639 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
640 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
641
642 a.pushToOutbox(ctx, AgentMessage{
643 Type: CompactMessageType,
644 Content: compactionMsg,
645 })
646
647 a.pushToOutbox(ctx, AgentMessage{
648 Type: UserMessageType,
649 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),
650 })
651 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)
652
653 return nil
654}
655
Earl Lee2e463fb2025-04-17 11:22:22 -0700656func (a *Agent) URL() string { return a.url }
657
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000658// BranchName returns the git branch name for the conversation.
659func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700660 return a.gitState.BranchName(a.config.BranchPrefix)
661}
662
663// Slug returns the slug identifier for this conversation.
664func (a *Agent) Slug() string {
665 return a.gitState.Slug()
666}
667
668// IncrementRetryNumber increments the retry number for branch naming conflicts
669func (a *Agent) IncrementRetryNumber() {
670 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000671}
672
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000673// OutstandingLLMCallCount returns the number of outstanding LLM calls.
674func (a *Agent) OutstandingLLMCallCount() int {
675 a.mu.Lock()
676 defer a.mu.Unlock()
677 return len(a.outstandingLLMCalls)
678}
679
680// OutstandingToolCalls returns the names of outstanding tool calls.
681func (a *Agent) OutstandingToolCalls() []string {
682 a.mu.Lock()
683 defer a.mu.Unlock()
684
685 tools := make([]string, 0, len(a.outstandingToolCalls))
686 for _, toolName := range a.outstandingToolCalls {
687 tools = append(tools, toolName)
688 }
689 return tools
690}
691
Earl Lee2e463fb2025-04-17 11:22:22 -0700692// OS returns the operating system of the client.
693func (a *Agent) OS() string {
694 return a.config.ClientGOOS
695}
696
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000697func (a *Agent) SessionID() string {
698 return a.config.SessionID
699}
700
philip.zeyliger8773e682025-06-11 21:36:21 -0700701// SSHConnectionString returns the SSH connection string for the container.
702func (a *Agent) SSHConnectionString() string {
703 return a.config.SSHConnectionString
704}
705
Philip Zeyliger18532b22025-04-23 21:11:46 +0000706// OutsideOS returns the operating system of the outside system.
707func (a *Agent) OutsideOS() string {
708 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000709}
710
Philip Zeyliger18532b22025-04-23 21:11:46 +0000711// OutsideHostname returns the hostname of the outside system.
712func (a *Agent) OutsideHostname() string {
713 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000714}
715
Philip Zeyliger18532b22025-04-23 21:11:46 +0000716// OutsideWorkingDir returns the working directory on the outside system.
717func (a *Agent) OutsideWorkingDir() string {
718 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000719}
720
721// GitOrigin returns the URL of the git remote 'origin' if it exists.
722func (a *Agent) GitOrigin() string {
723 return a.gitOrigin
724}
725
Philip Zeyliger64f60462025-06-16 13:57:10 -0700726// DiffStats returns the number of lines added and removed from sketch-base to HEAD
727func (a *Agent) DiffStats() (int, int) {
728 return a.gitState.DiffStats()
729}
730
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000731func (a *Agent) OpenBrowser(url string) {
732 if !a.IsInContainer() {
733 browser.Open(url)
734 return
735 }
736 // We're in Docker, need to send a request to the Git server
737 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700738 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000739 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700740 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000741 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700742 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000743 return
744 }
745 defer resp.Body.Close()
746 if resp.StatusCode == http.StatusOK {
747 return
748 }
749 body, _ := io.ReadAll(resp.Body)
750 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
751}
752
Sean McCullough96b60dd2025-04-30 09:49:10 -0700753// CurrentState returns the current state of the agent's state machine.
754func (a *Agent) CurrentState() State {
755 return a.stateMachine.CurrentState()
756}
757
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700758func (a *Agent) IsInContainer() bool {
759 return a.config.InDocker
760}
761
762func (a *Agent) FirstMessageIndex() int {
763 a.mu.Lock()
764 defer a.mu.Unlock()
765 return a.firstMessageIndex
766}
767
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700768// SetSlug sets a human-readable identifier for the conversation.
769func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700770 a.mu.Lock()
771 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700772
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700773 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000774 convo, ok := a.convo.(*conversation.Convo)
775 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700776 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000777 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700778}
779
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000780// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700781func (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 +0000782 // Track the tool call
783 a.mu.Lock()
784 a.outstandingToolCalls[id] = toolName
785 a.mu.Unlock()
786}
787
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700788// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
789// If there's only one element in the array and it's a text type, it returns that text directly.
790// It also processes nested ToolResult arrays recursively.
791func contentToString(contents []llm.Content) string {
792 if len(contents) == 0 {
793 return ""
794 }
795
796 // If there's only one element and it's a text type, return it directly
797 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
798 return contents[0].Text
799 }
800
801 // Otherwise, concatenate all text content
802 var result strings.Builder
803 for _, content := range contents {
804 if content.Type == llm.ContentTypeText {
805 result.WriteString(content.Text)
806 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
807 // Recursively process nested tool results
808 result.WriteString(contentToString(content.ToolResult))
809 }
810 }
811
812 return result.String()
813}
814
Earl Lee2e463fb2025-04-17 11:22:22 -0700815// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700816func (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 +0000817 // Remove the tool call from outstanding calls
818 a.mu.Lock()
819 delete(a.outstandingToolCalls, toolID)
820 a.mu.Unlock()
821
Earl Lee2e463fb2025-04-17 11:22:22 -0700822 m := AgentMessage{
823 Type: ToolUseMessageType,
824 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700825 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700826 ToolError: content.ToolError,
827 ToolName: toolName,
828 ToolInput: string(toolInput),
829 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700830 StartTime: content.ToolUseStartTime,
831 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700832 }
833
834 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700835 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
836 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700837 m.Elapsed = &elapsed
838 }
839
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700840 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700841 a.pushToOutbox(ctx, m)
842}
843
844// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700845func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000846 a.mu.Lock()
847 defer a.mu.Unlock()
848 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700849 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
850}
851
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700852// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700853// that need to be displayed (as well as tool calls that we send along when
854// they're done). (It would be reasonable to also mention tool calls when they're
855// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700856func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000857 // Remove the LLM call from outstanding calls
858 a.mu.Lock()
859 delete(a.outstandingLLMCalls, id)
860 a.mu.Unlock()
861
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700862 if resp == nil {
863 // LLM API call failed
864 m := AgentMessage{
865 Type: ErrorMessageType,
866 Content: "API call failed, type 'continue' to try again",
867 }
868 m.SetConvo(convo)
869 a.pushToOutbox(ctx, m)
870 return
871 }
872
Earl Lee2e463fb2025-04-17 11:22:22 -0700873 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700874 if convo.Parent == nil { // subconvos never end the turn
875 switch resp.StopReason {
876 case llm.StopReasonToolUse:
877 // Check whether any of the tool calls are for tools that should end the turn
878 ToolSearch:
879 for _, part := range resp.Content {
880 if part.Type != llm.ContentTypeToolUse {
881 continue
882 }
Sean McCullough021557a2025-05-05 23:20:53 +0000883 // Find the tool by name
884 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700885 if tool.Name == part.ToolName {
886 endOfTurn = tool.EndsTurn
887 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000888 }
889 }
Sean McCullough021557a2025-05-05 23:20:53 +0000890 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700891 default:
892 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000893 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700894 }
895 m := AgentMessage{
896 Type: AgentMessageType,
897 Content: collectTextContent(resp),
898 EndOfTurn: endOfTurn,
899 Usage: &resp.Usage,
900 StartTime: resp.StartTime,
901 EndTime: resp.EndTime,
902 }
903
904 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700905 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700906 var toolCalls []ToolCall
907 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700908 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700909 toolCalls = append(toolCalls, ToolCall{
910 Name: part.ToolName,
911 Input: string(part.ToolInput),
912 ToolCallId: part.ID,
913 })
914 }
915 }
916 m.ToolCalls = toolCalls
917 }
918
919 // Calculate the elapsed time if both start and end times are set
920 if resp.StartTime != nil && resp.EndTime != nil {
921 elapsed := resp.EndTime.Sub(*resp.StartTime)
922 m.Elapsed = &elapsed
923 }
924
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700925 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700926 a.pushToOutbox(ctx, m)
927}
928
929// WorkingDir implements CodingAgent.
930func (a *Agent) WorkingDir() string {
931 return a.workingDir
932}
933
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000934// RepoRoot returns the git repository root directory.
935func (a *Agent) RepoRoot() string {
936 return a.repoRoot
937}
938
Earl Lee2e463fb2025-04-17 11:22:22 -0700939// MessageCount implements CodingAgent.
940func (a *Agent) MessageCount() int {
941 a.mu.Lock()
942 defer a.mu.Unlock()
943 return len(a.history)
944}
945
946// Messages implements CodingAgent.
947func (a *Agent) Messages(start int, end int) []AgentMessage {
948 a.mu.Lock()
949 defer a.mu.Unlock()
950 return slices.Clone(a.history[start:end])
951}
952
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700953// ShouldCompact checks if the conversation should be compacted based on token usage
954func (a *Agent) ShouldCompact() bool {
955 // Get the threshold from environment variable, default to 0.94 (94%)
956 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
957 // and a little bit of buffer.)
958 thresholdRatio := 0.94
959 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
960 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
961 thresholdRatio = parsed
962 }
963 }
964
965 // Get the most recent usage to check current context size
966 lastUsage := a.convo.LastUsage()
967
968 if lastUsage.InputTokens == 0 {
969 // No API calls made yet
970 return false
971 }
972
973 // Calculate the current context size from the last API call
974 // This includes all tokens that were part of the input context:
975 // - Input tokens (user messages, system prompt, conversation history)
976 // - Cache read tokens (cached parts of the context)
977 // - Cache creation tokens (new parts being cached)
978 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
979
980 // Get the service's token context window
981 service := a.config.Service
982 contextWindow := service.TokenContextWindow()
983
984 // Calculate threshold
985 threshold := uint64(float64(contextWindow) * thresholdRatio)
986
987 // Check if we've exceeded the threshold
988 return currentContextSize >= threshold
989}
990
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700991func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700992 return a.originalBudget
993}
994
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000995// Upstream returns the upstream branch for git work
996func (a *Agent) Upstream() string {
997 return a.gitState.Upstream()
998}
999
Earl Lee2e463fb2025-04-17 11:22:22 -07001000// AgentConfig contains configuration for creating a new Agent.
1001type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001002 Context context.Context
1003 Service llm.Service
1004 Budget conversation.Budget
1005 GitUsername string
1006 GitEmail string
1007 SessionID string
1008 ClientGOOS string
1009 ClientGOARCH string
1010 InDocker bool
1011 OneShot bool
1012 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +00001013 // Outside information
1014 OutsideHostname string
1015 OutsideOS string
1016 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001017
1018 // Outtie's HTTP to, e.g., open a browser
1019 OutsideHTTP string
1020 // Outtie's Git server
1021 GitRemoteAddr string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001022 // Upstream branch for git work
1023 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001024 // Commit to checkout from Outtie
1025 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001026 // Prefix for git branches created by sketch
1027 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001028 // LinkToGitHub enables GitHub branch linking in UI
1029 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001030 // SSH connection string for connecting to the container
1031 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001032 // Skaband client for session history (optional)
1033 SkabandClient *skabandclient.SkabandClient
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001034 // MCP server configurations
1035 MCPServers []string
Earl Lee2e463fb2025-04-17 11:22:22 -07001036}
1037
1038// NewAgent creates a new Agent.
1039// It is not usable until Init() is called.
1040func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001041 // Set default branch prefix if not specified
1042 if config.BranchPrefix == "" {
1043 config.BranchPrefix = "sketch/"
1044 }
1045
Earl Lee2e463fb2025-04-17 11:22:22 -07001046 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001047 config: config,
1048 ready: make(chan struct{}),
1049 inbox: make(chan string, 100),
1050 subscribers: make([]chan *AgentMessage, 0),
1051 startedAt: time.Now(),
1052 originalBudget: config.Budget,
1053 gitState: AgentGitState{
1054 seenCommits: make(map[string]bool),
1055 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001056 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001057 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001058 outsideHostname: config.OutsideHostname,
1059 outsideOS: config.OutsideOS,
1060 outsideWorkingDir: config.OutsideWorkingDir,
1061 outstandingLLMCalls: make(map[string]struct{}),
1062 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001063 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001064 workingDir: config.WorkingDir,
1065 outsideHTTP: config.OutsideHTTP,
Sean McCullough364f7412025-06-02 00:55:44 +00001066 portMonitor: NewPortMonitor(),
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001067 mcpManager: mcp.NewMCPManager(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001068 }
1069 return agent
1070}
1071
1072type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001073 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001074
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001075 InDocker bool
1076 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001077}
1078
1079func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001080 if a.convo != nil {
1081 return fmt.Errorf("Agent.Init: already initialized")
1082 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001083 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001084 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001085
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001086 if !ini.NoGit {
1087 // Capture the original origin before we potentially replace it below
1088 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
1089 }
1090
Philip Zeyliger222bf412025-06-04 16:42:58 +00001091 // If a remote git addr was specified, we configure the origin remote
Philip Zeyligerf2872992025-05-22 10:35:28 -07001092 if a.gitState.gitRemoteAddr != "" {
1093 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
Philip Zeyliger222bf412025-06-04 16:42:58 +00001094
1095 // Remove existing origin remote if it exists
1096 cmd := exec.CommandContext(ctx, "git", "remote", "remove", "origin")
Philip Zeyligerf2872992025-05-22 10:35:28 -07001097 cmd.Dir = a.workingDir
1098 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001099 // Ignore error if origin doesn't exist
1100 slog.DebugContext(ctx, "git remote remove origin (ignoring if not exists)", slog.String("output", string(out)))
Philip Zeyligerf2872992025-05-22 10:35:28 -07001101 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001102
1103 // Add the new remote as origin
1104 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", a.gitState.gitRemoteAddr)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001105 cmd.Dir = a.workingDir
1106 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001107 return fmt.Errorf("git remote add origin: %s: %v", out, err)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001108 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001109
Philip Zeyligerf2872992025-05-22 10:35:28 -07001110 }
1111
1112 // If a commit was specified, we fetch and reset to it.
1113 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001114 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1115
Earl Lee2e463fb2025-04-17 11:22:22 -07001116 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001117 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001118 if out, err := cmd.CombinedOutput(); err != nil {
1119 return fmt.Errorf("git stash: %s: %v", out, err)
1120 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001121 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001122 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001123 if out, err := cmd.CombinedOutput(); err != nil {
1124 return fmt.Errorf("git fetch: %s: %w", out, err)
1125 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001126 // The -B resets the branch if it already exists (or creates it if it doesn't)
1127 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001128 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001129 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1130 // Remove git hooks if they exist and retry
1131 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001132 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001133 if _, statErr := os.Stat(hookPath); statErr == nil {
1134 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1135 slog.String("error", err.Error()),
1136 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001137 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001138 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1139 }
1140
1141 // Retry the checkout operation
Philip Zeyliger1417b692025-06-12 11:07:04 -07001142 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001143 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001144 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001145 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 +01001146 }
1147 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001148 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001149 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001150 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001151 } else if a.IsInContainer() {
1152 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1153 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1154 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1155 cmd.Dir = a.workingDir
1156 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1157 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1158 }
1159 } else {
1160 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001161 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001162
1163 if ini.HostAddr != "" {
1164 a.url = "http://" + ini.HostAddr
1165 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001166
1167 if !ini.NoGit {
1168 repoRoot, err := repoRoot(ctx, a.workingDir)
1169 if err != nil {
1170 return fmt.Errorf("repoRoot: %w", err)
1171 }
1172 a.repoRoot = repoRoot
1173
Earl Lee2e463fb2025-04-17 11:22:22 -07001174 if err != nil {
1175 return fmt.Errorf("resolveRef: %w", err)
1176 }
Philip Zeyliger49edc922025-05-14 09:45:45 -07001177
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001178 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001179 if err := setupGitHooks(a.repoRoot); err != nil {
1180 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1181 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001182 }
1183
Philip Zeyliger49edc922025-05-14 09:45:45 -07001184 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1185 cmd.Dir = repoRoot
1186 if out, err := cmd.CombinedOutput(); err != nil {
1187 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1188 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001189
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001190 slog.Info("running codebase analysis")
1191 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1192 if err != nil {
1193 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001194 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001195 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001196
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001197 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001198 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001199 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001200 }
1201 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001202
Earl Lee2e463fb2025-04-17 11:22:22 -07001203 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001204 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001205 a.convo = a.initConvo()
1206 close(a.ready)
1207 return nil
1208}
1209
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001210//go:embed agent_system_prompt.txt
1211var agentSystemPrompt string
1212
Earl Lee2e463fb2025-04-17 11:22:22 -07001213// initConvo initializes the conversation.
1214// It must not be called until all agent fields are initialized,
1215// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001216func (a *Agent) initConvo() *conversation.Convo {
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001217 return a.initConvoWithUsage(nil)
1218}
1219
1220// initConvoWithUsage initializes the conversation with optional preserved usage.
1221func (a *Agent) initConvoWithUsage(usage *conversation.CumulativeUsage) *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001222 ctx := a.config.Context
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001223 convo := conversation.New(ctx, a.config.Service, usage)
Earl Lee2e463fb2025-04-17 11:22:22 -07001224 convo.PromptCaching = true
1225 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001226 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001227 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001228
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001229 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1230 bashPermissionCheck := func(command string) error {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001231 if a.gitState.Slug() != "" {
1232 return nil // branch is set up
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001233 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001234 willCommit, err := bashkit.WillRunGitCommit(command)
1235 if err != nil {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001236 return nil // fail open
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001237 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001238 if willCommit {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001239 return fmt.Errorf("you must use the set-slug tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001240 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001241 return nil
1242 }
1243
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001244 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001245
Earl Lee2e463fb2025-04-17 11:22:22 -07001246 // Register all tools with the conversation
1247 // When adding, removing, or modifying tools here, double-check that the termui tool display
1248 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001249
1250 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001251 _, supportsScreenshots := a.config.Service.(*ant.Service)
1252 var bTools []*llm.Tool
1253 var browserCleanup func()
1254
1255 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1256 // Add cleanup function to context cancel
1257 go func() {
1258 <-a.config.Context.Done()
1259 browserCleanup()
1260 }()
1261 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001262
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001263 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001264 bashTool, claudetool.Keyword, claudetool.Patch,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001265 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.setSlugTool(), a.commitMessageStyleTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001266 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001267 }
1268
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001269 // One-shot mode is non-interactive, multiple choice requires human response
1270 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001271 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001272 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001273
1274 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001275
1276 // Add session history tools if skaband client is available
1277 if a.config.SkabandClient != nil {
1278 sessionHistoryTools := claudetool.CreateSessionHistoryTools(a.config.SkabandClient, a.config.SessionID, a.gitOrigin)
1279 convo.Tools = append(convo.Tools, sessionHistoryTools...)
1280 }
1281
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001282 // Add MCP tools if configured
1283 if len(a.config.MCPServers) > 0 {
1284 slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers))
1285 mcpConnections, mcpErrors := a.mcpManager.ConnectToServers(ctx, a.config.MCPServers, 10*time.Second)
1286
1287 if len(mcpErrors) > 0 {
1288 for _, err := range mcpErrors {
1289 slog.ErrorContext(ctx, "MCP connection error", "error", err)
1290 // Send agent message about MCP connection failures
1291 a.pushToOutbox(ctx, AgentMessage{
1292 Type: ErrorMessageType,
1293 Content: fmt.Sprintf("MCP server connection failed: %v", err),
1294 })
1295 }
1296 }
1297
1298 if len(mcpConnections) > 0 {
1299 // Add tools from all successful connections
1300 totalTools := 0
1301 for _, connection := range mcpConnections {
1302 convo.Tools = append(convo.Tools, connection.Tools...)
1303 totalTools += len(connection.Tools)
1304 // Log tools per server using structured data
1305 slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames)
1306 }
1307 slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools)
1308 } else {
1309 slog.InfoContext(ctx, "No MCP tools available after connection attempts")
1310 }
1311 }
1312
Earl Lee2e463fb2025-04-17 11:22:22 -07001313 convo.Listener = a
1314 return convo
1315}
1316
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001317var multipleChoiceTool = &llm.Tool{
1318 Name: "multiplechoice",
1319 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.",
1320 EndsTurn: true,
1321 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001322 "type": "object",
1323 "description": "The question and a list of answers you would expect the user to choose from.",
1324 "properties": {
1325 "question": {
1326 "type": "string",
1327 "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?'"
1328 },
1329 "responseOptions": {
1330 "type": "array",
1331 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1332 "items": {
1333 "type": "object",
1334 "properties": {
1335 "caption": {
1336 "type": "string",
1337 "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'"
1338 },
1339 "responseText": {
1340 "type": "string",
1341 "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'"
1342 }
1343 },
1344 "required": ["caption", "responseText"]
1345 }
1346 }
1347 },
1348 "required": ["question", "responseOptions"]
1349}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001350 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1351 // The Run logic for "multiplechoice" tool is a no-op on the server.
1352 // The UI will present a list of options for the user to select from,
1353 // and that's it as far as "executing" the tool_use goes.
1354 // When the user *does* select one of the presented options, that
1355 // responseText gets sent as a chat message on behalf of the user.
1356 return llm.TextContent("end your turn and wait for the user to respond"), nil
1357 },
Sean McCullough485afc62025-04-28 14:28:39 -07001358}
1359
1360type MultipleChoiceOption struct {
1361 Caption string `json:"caption"`
1362 ResponseText string `json:"responseText"`
1363}
1364
1365type MultipleChoiceParams struct {
1366 Question string `json:"question"`
1367 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1368}
1369
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001370// branchExists reports whether branchName exists, either locally or in well-known remotes.
1371func branchExists(dir, branchName string) bool {
1372 refs := []string{
1373 "refs/heads/",
1374 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001375 }
1376 for _, ref := range refs {
1377 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1378 cmd.Dir = dir
1379 if cmd.Run() == nil { // exit code 0 means branch exists
1380 return true
1381 }
1382 }
1383 return false
1384}
1385
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001386func (a *Agent) setSlugTool() *llm.Tool {
1387 return &llm.Tool{
1388 Name: "set-slug",
1389 Description: `Set a short slug as an identifier for this conversation.`,
Earl Lee2e463fb2025-04-17 11:22:22 -07001390 InputSchema: json.RawMessage(`{
1391 "type": "object",
1392 "properties": {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001393 "slug": {
Earl Lee2e463fb2025-04-17 11:22:22 -07001394 "type": "string",
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001395 "description": "A 2-3 word alphanumeric hyphenated slug, imperative tense"
Earl Lee2e463fb2025-04-17 11:22:22 -07001396 }
1397 },
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001398 "required": ["slug"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001399}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001400 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001401 var params struct {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001402 Slug string `json:"slug"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001403 }
1404 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001405 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001406 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001407 // Prevent slug changes if there have been git changes
1408 // This lets the agent change its mind about a good slug,
1409 // while ensuring that once a branch has been pushed, it remains stable.
1410 if s := a.Slug(); s != "" && s != params.Slug && a.gitState.HasSeenCommits() {
1411 return nil, fmt.Errorf("slug already set to %q", s)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001412 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001413 if params.Slug == "" {
1414 return nil, fmt.Errorf("slug parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001415 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001416 slug := cleanSlugName(params.Slug)
1417 if slug == "" {
1418 return nil, fmt.Errorf("slug parameter could not be converted to a valid slug")
1419 }
1420 a.SetSlug(slug)
1421 // TODO: do this by a call to outie, rather than semi-guessing from innie
1422 if branchExists(a.workingDir, a.BranchName()) {
1423 return nil, fmt.Errorf("slug %q already exists; please choose a different slug", slug)
1424 }
1425 return llm.TextContent("OK"), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001426 },
1427 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001428}
1429
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001430func (a *Agent) commitMessageStyleTool() *llm.Tool {
1431 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 +00001432 preCommit := &llm.Tool{
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001433 Name: "commit-message-style",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001434 Description: description,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001435 InputSchema: llm.EmptySchema(),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001436 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001437 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1438 if err != nil {
1439 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1440 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001441 return llm.TextContent(styleHint), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001442 },
1443 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001444 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001445}
1446
1447func (a *Agent) Ready() <-chan struct{} {
1448 return a.ready
1449}
1450
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001451// BranchPrefix returns the configured branch prefix
1452func (a *Agent) BranchPrefix() string {
1453 return a.config.BranchPrefix
1454}
1455
philip.zeyliger6d3de482025-06-10 19:38:14 -07001456// LinkToGitHub returns whether GitHub branch linking is enabled
1457func (a *Agent) LinkToGitHub() bool {
1458 return a.config.LinkToGitHub
1459}
1460
Earl Lee2e463fb2025-04-17 11:22:22 -07001461func (a *Agent) UserMessage(ctx context.Context, msg string) {
1462 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1463 a.inbox <- msg
1464}
1465
Earl Lee2e463fb2025-04-17 11:22:22 -07001466func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1467 return a.convo.CancelToolUse(toolUseID, cause)
1468}
1469
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001470func (a *Agent) CancelTurn(cause error) {
1471 a.cancelTurnMu.Lock()
1472 defer a.cancelTurnMu.Unlock()
1473 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001474 // Force state transition to cancelled state
1475 ctx := a.config.Context
1476 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001477 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001478 }
1479}
1480
1481func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001482 // Start port monitoring when the agent loop begins
1483 // Only monitor ports when running in a container
1484 if a.IsInContainer() {
1485 a.portMonitor.Start(ctxOuter)
1486 }
1487
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001488 // Set up cleanup when context is done
1489 defer func() {
1490 if a.mcpManager != nil {
1491 a.mcpManager.Close()
1492 }
1493 }()
1494
Earl Lee2e463fb2025-04-17 11:22:22 -07001495 for {
1496 select {
1497 case <-ctxOuter.Done():
1498 return
1499 default:
1500 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001501 a.cancelTurnMu.Lock()
1502 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001503 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001504 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001505 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001506 a.cancelTurn = cancel
1507 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001508 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1509 if err != nil {
1510 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1511 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001512 cancel(nil)
1513 }
1514 }
1515}
1516
1517func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1518 if m.Timestamp.IsZero() {
1519 m.Timestamp = time.Now()
1520 }
1521
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001522 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1523 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1524 m.Content = m.ToolResult
1525 }
1526
Earl Lee2e463fb2025-04-17 11:22:22 -07001527 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1528 if m.EndOfTurn && m.Type == AgentMessageType {
1529 turnDuration := time.Since(a.startOfTurn)
1530 m.TurnDuration = &turnDuration
1531 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1532 }
1533
Earl Lee2e463fb2025-04-17 11:22:22 -07001534 a.mu.Lock()
1535 defer a.mu.Unlock()
1536 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001537 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001538 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001539
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001540 // Notify all subscribers
1541 for _, ch := range a.subscribers {
1542 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001543 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001544}
1545
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001546func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1547 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001548 if block {
1549 select {
1550 case <-ctx.Done():
1551 return m, ctx.Err()
1552 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001553 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001554 }
1555 }
1556 for {
1557 select {
1558 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001559 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001560 default:
1561 return m, nil
1562 }
1563 }
1564}
1565
Sean McCullough885a16a2025-04-30 02:49:25 +00001566// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001567func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001568 // Reset the start of turn time
1569 a.startOfTurn = time.Now()
1570
Sean McCullough96b60dd2025-04-30 09:49:10 -07001571 // Transition to waiting for user input state
1572 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1573
Sean McCullough885a16a2025-04-30 02:49:25 +00001574 // Process initial user message
1575 initialResp, err := a.processUserMessage(ctx)
1576 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001577 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001578 return err
1579 }
1580
1581 // Handle edge case where both initialResp and err are nil
1582 if initialResp == nil {
1583 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001584 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1585
Sean McCullough9f4b8082025-04-30 17:34:07 +00001586 a.pushToOutbox(ctx, errorMessage(err))
1587 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001588 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001589
Earl Lee2e463fb2025-04-17 11:22:22 -07001590 // We do this as we go, but let's also do it at the end of the turn
1591 defer func() {
1592 if _, err := a.handleGitCommits(ctx); err != nil {
1593 // Just log the error, don't stop execution
1594 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1595 }
1596 }()
1597
Sean McCullougha1e0e492025-05-01 10:51:08 -07001598 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001599 resp := initialResp
1600 for {
1601 // Check if we are over budget
1602 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001603 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001604 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001605 }
1606
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001607 // Check if we should compact the conversation
1608 if a.ShouldCompact() {
1609 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1610 if err := a.CompactConversation(ctx); err != nil {
1611 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1612 return err
1613 }
1614 // After compaction, end this turn and start fresh
1615 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1616 return nil
1617 }
1618
Sean McCullough885a16a2025-04-30 02:49:25 +00001619 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001620 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001621 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001622 break
1623 }
1624
Sean McCullough96b60dd2025-04-30 09:49:10 -07001625 // Transition to tool use requested state
1626 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1627
Sean McCullough885a16a2025-04-30 02:49:25 +00001628 // Handle tool execution
1629 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1630 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001631 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001632 }
1633
Sean McCullougha1e0e492025-05-01 10:51:08 -07001634 if toolResp == nil {
1635 return fmt.Errorf("cannot continue conversation with a nil tool response")
1636 }
1637
Sean McCullough885a16a2025-04-30 02:49:25 +00001638 // Set the response for the next iteration
1639 resp = toolResp
1640 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001641
1642 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001643}
1644
1645// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001646func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001647 // Wait for at least one message from the user
1648 msgs, err := a.GatherMessages(ctx, true)
1649 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001650 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001651 return nil, err
1652 }
1653
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001654 userMessage := llm.Message{
1655 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001656 Content: msgs,
1657 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001658
Sean McCullough96b60dd2025-04-30 09:49:10 -07001659 // Transition to sending to LLM state
1660 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1661
Sean McCullough885a16a2025-04-30 02:49:25 +00001662 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001663 resp, err := a.convo.SendMessage(userMessage)
1664 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001665 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001666 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001667 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001668 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001669
Sean McCullough96b60dd2025-04-30 09:49:10 -07001670 // Transition to processing LLM response state
1671 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1672
Sean McCullough885a16a2025-04-30 02:49:25 +00001673 return resp, nil
1674}
1675
1676// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001677func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1678 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001679 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001680 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001681
Sean McCullough96b60dd2025-04-30 09:49:10 -07001682 // Transition to checking for cancellation state
1683 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1684
Sean McCullough885a16a2025-04-30 02:49:25 +00001685 // Check if the operation was cancelled by the user
1686 select {
1687 case <-ctx.Done():
1688 // Don't actually run any of the tools, but rather build a response
1689 // for each tool_use message letting the LLM know that user canceled it.
1690 var err error
1691 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001692 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001693 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001694 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001695 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001696 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001697 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001698 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001699 // Transition to running tool state
1700 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1701
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001702 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001703 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001704 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001705
1706 // Execute the tools
1707 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001708 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001709 if ctx.Err() != nil { // e.g. the user canceled the operation
1710 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001711 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001712 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001713 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001714 a.pushToOutbox(ctx, errorMessage(err))
1715 }
1716 }
1717
1718 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001719 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001720 autoqualityMessages := a.processGitChanges(ctx)
1721
1722 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001723 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001724 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001725 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001726 return false, nil
1727 }
1728
1729 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001730 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1731 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001732}
1733
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001734// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001735func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001736 // Check for git commits
1737 _, err := a.handleGitCommits(ctx)
1738 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001739 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001740 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001741 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001742 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001743}
1744
1745// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1746// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001747func (a *Agent) processGitChanges(ctx context.Context) []string {
1748 // Check for git commits after tool execution
1749 newCommits, err := a.handleGitCommits(ctx)
1750 if err != nil {
1751 // Just log the error, don't stop execution
1752 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1753 return nil
1754 }
1755
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001756 // Run mechanical checks if there was exactly one new commit.
1757 if len(newCommits) != 1 {
1758 return nil
1759 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001760 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001761 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1762 msg := a.codereview.RunMechanicalChecks(ctx)
1763 if msg != "" {
1764 a.pushToOutbox(ctx, AgentMessage{
1765 Type: AutoMessageType,
1766 Content: msg,
1767 Timestamp: time.Now(),
1768 })
1769 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001770 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001771
1772 return autoqualityMessages
1773}
1774
1775// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001776func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001777 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001778 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001779 msgs, err := a.GatherMessages(ctx, false)
1780 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001781 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001782 return false, nil
1783 }
1784
1785 // Inject any auto-generated messages from quality checks
1786 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001787 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001788 }
1789
1790 // Handle cancellation by appending a message about it
1791 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001792 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001793 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001794 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001795 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1796 } else if err := a.convo.OverBudget(); err != nil {
1797 // Handle budget issues by appending a message about it
1798 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 -07001799 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001800 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1801 }
1802
1803 // Combine tool results with user messages
1804 results = append(results, msgs...)
1805
1806 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001807 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001808 resp, err := a.convo.SendMessage(llm.Message{
1809 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001810 Content: results,
1811 })
1812 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001813 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001814 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1815 return true, nil // Return true to continue the conversation, but with no response
1816 }
1817
Sean McCullough96b60dd2025-04-30 09:49:10 -07001818 // Transition back to processing LLM response
1819 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1820
Sean McCullough885a16a2025-04-30 02:49:25 +00001821 if cancelled {
1822 return false, nil
1823 }
1824
1825 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001826}
1827
1828func (a *Agent) overBudget(ctx context.Context) error {
1829 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001830 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001831 m := budgetMessage(err)
1832 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001833 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001834 a.convo.ResetBudget(a.originalBudget)
1835 return err
1836 }
1837 return nil
1838}
1839
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001840func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001841 // Collect all text content
1842 var allText strings.Builder
1843 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001844 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001845 if allText.Len() > 0 {
1846 allText.WriteString("\n\n")
1847 }
1848 allText.WriteString(content.Text)
1849 }
1850 }
1851 return allText.String()
1852}
1853
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001854func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001855 a.mu.Lock()
1856 defer a.mu.Unlock()
1857 return a.convo.CumulativeUsage()
1858}
1859
Earl Lee2e463fb2025-04-17 11:22:22 -07001860// Diff returns a unified diff of changes made since the agent was instantiated.
1861func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001862 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001863 return "", fmt.Errorf("no initial commit reference available")
1864 }
1865
1866 // Find the repository root
1867 ctx := context.Background()
1868
1869 // If a specific commit hash is provided, show just that commit's changes
1870 if commit != nil && *commit != "" {
1871 // Validate that the commit looks like a valid git SHA
1872 if !isValidGitSHA(*commit) {
1873 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1874 }
1875
1876 // Get the diff for just this commit
1877 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1878 cmd.Dir = a.repoRoot
1879 output, err := cmd.CombinedOutput()
1880 if err != nil {
1881 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1882 }
1883 return string(output), nil
1884 }
1885
1886 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001887 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001888 cmd.Dir = a.repoRoot
1889 output, err := cmd.CombinedOutput()
1890 if err != nil {
1891 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1892 }
1893
1894 return string(output), nil
1895}
1896
Philip Zeyliger49edc922025-05-14 09:45:45 -07001897// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1898// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1899func (a *Agent) SketchGitBaseRef() string {
1900 if a.IsInContainer() {
1901 return "sketch-base"
1902 } else {
1903 return "sketch-base-" + a.SessionID()
1904 }
1905}
1906
1907// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1908func (a *Agent) SketchGitBase() string {
1909 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1910 cmd.Dir = a.repoRoot
1911 output, err := cmd.CombinedOutput()
1912 if err != nil {
1913 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1914 return "HEAD"
1915 }
1916 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001917}
1918
Pokey Rule7a113622025-05-12 10:58:45 +01001919// removeGitHooks removes the Git hooks directory from the repository
1920func removeGitHooks(_ context.Context, repoPath string) error {
1921 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1922
1923 // Check if hooks directory exists
1924 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1925 // Directory doesn't exist, nothing to do
1926 return nil
1927 }
1928
1929 // Remove the hooks directory
1930 err := os.RemoveAll(hooksDir)
1931 if err != nil {
1932 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1933 }
1934
1935 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001936 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001937 if err != nil {
1938 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1939 }
1940
1941 return nil
1942}
1943
Philip Zeyligerf2872992025-05-22 10:35:28 -07001944func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001945 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001946 for _, msg := range msgs {
1947 a.pushToOutbox(ctx, msg)
1948 }
1949 return commits, error
1950}
1951
Earl Lee2e463fb2025-04-17 11:22:22 -07001952// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001953// under docker, new HEADs are pushed to a branch according to the slug.
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001954func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001955 ags.mu.Lock()
1956 defer ags.mu.Unlock()
1957
1958 msgs := []AgentMessage{}
1959 if repoRoot == "" {
1960 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001961 }
1962
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001963 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07001964 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001965 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001966 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001967 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001968 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001969 }
1970 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001971 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07001972 }()
1973
Philip Zeyliger64f60462025-06-16 13:57:10 -07001974 // Compute diff stats from baseRef to HEAD when HEAD changes
1975 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
1976 // Log error but don't fail the entire operation
1977 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
1978 } else {
1979 // Set diff stats directly since we already hold the mutex
1980 ags.linesAdded = added
1981 ags.linesRemoved = removed
1982 }
1983
Earl Lee2e463fb2025-04-17 11:22:22 -07001984 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1985 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1986 // to the last 100 commits.
1987 var commits []*GitCommit
1988
1989 // Get commits since the initial commit
1990 // Format: <hash>\0<subject>\0<body>\0
1991 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1992 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001993 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 -07001994 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001995 output, err := cmd.Output()
1996 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001997 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001998 }
1999
2000 // Parse git log output and filter out already seen commits
2001 parsedCommits := parseGitLog(string(output))
2002
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002003 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07002004
2005 // Filter out commits we've already seen
2006 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002007 if commit.Hash == sketch {
2008 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07002009 }
2010
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002011 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
2012 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07002013 continue
2014 }
2015
2016 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07002017 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07002018
2019 // Add to our list of new commits
2020 commits = append(commits, &commit)
2021 }
2022
Philip Zeyligerf2872992025-05-22 10:35:28 -07002023 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002024 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07002025 // 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 -07002026 sketchCommit = &GitCommit{}
2027 sketchCommit.Hash = sketch
2028 sketchCommit.Subject = "unknown"
2029 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07002030 }
2031
Earl Lee2e463fb2025-04-17 11:22:22 -07002032 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
2033 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
2034 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00002035
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002036 // 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 +00002037 var out []byte
2038 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002039 originalRetryNumber := ags.retryNumber
2040 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00002041 for retries := range 10 {
2042 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07002043 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002044 }
2045
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002046 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002047 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002048 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002049 out, err = cmd.CombinedOutput()
2050
2051 if err == nil {
2052 // Success! Break out of the retry loop
2053 break
2054 }
2055
2056 // Check if this is the "refusing to update checked out branch" error
2057 if !strings.Contains(string(out), "refusing to update checked out branch") {
2058 // This is a different error, so don't retry
2059 break
2060 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002061 }
2062
2063 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002064 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002065 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002066 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002067 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002068 if ags.retryNumber != originalRetryNumber {
2069 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002070 msgs = append(msgs, AgentMessage{
2071 Type: AutoMessageType,
2072 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002073 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 +00002074 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002075 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002076 }
2077 }
2078
2079 // If we found new commits, create a message
2080 if len(commits) > 0 {
2081 msg := AgentMessage{
2082 Type: CommitMessageType,
2083 Timestamp: time.Now(),
2084 Commits: commits,
2085 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002086 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002087 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002088 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002089}
2090
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002091func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002092 return strings.Map(func(r rune) rune {
2093 // lowercase
2094 if r >= 'A' && r <= 'Z' {
2095 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002096 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002097 // replace spaces with dashes
2098 if r == ' ' {
2099 return '-'
2100 }
2101 // allow alphanumerics and dashes
2102 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2103 return r
2104 }
2105 return -1
2106 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002107}
2108
2109// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2110// and returns an array of GitCommit structs.
2111func parseGitLog(output string) []GitCommit {
2112 var commits []GitCommit
2113
2114 // No output means no commits
2115 if len(output) == 0 {
2116 return commits
2117 }
2118
2119 // Split by NULL byte
2120 parts := strings.Split(output, "\x00")
2121
2122 // Process in triplets (hash, subject, body)
2123 for i := 0; i < len(parts); i++ {
2124 // Skip empty parts
2125 if parts[i] == "" {
2126 continue
2127 }
2128
2129 // This should be a hash
2130 hash := strings.TrimSpace(parts[i])
2131
2132 // Make sure we have at least a subject part available
2133 if i+1 >= len(parts) {
2134 break // No more parts available
2135 }
2136
2137 // Get the subject
2138 subject := strings.TrimSpace(parts[i+1])
2139
2140 // Get the body if available
2141 body := ""
2142 if i+2 < len(parts) {
2143 body = strings.TrimSpace(parts[i+2])
2144 }
2145
2146 // Skip to the next triplet
2147 i += 2
2148
2149 commits = append(commits, GitCommit{
2150 Hash: hash,
2151 Subject: subject,
2152 Body: body,
2153 })
2154 }
2155
2156 return commits
2157}
2158
2159func repoRoot(ctx context.Context, dir string) (string, error) {
2160 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2161 stderr := new(strings.Builder)
2162 cmd.Stderr = stderr
2163 cmd.Dir = dir
2164 out, err := cmd.Output()
2165 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002166 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002167 }
2168 return strings.TrimSpace(string(out)), nil
2169}
2170
2171func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2172 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2173 stderr := new(strings.Builder)
2174 cmd.Stderr = stderr
2175 cmd.Dir = dir
2176 out, err := cmd.Output()
2177 if err != nil {
2178 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2179 }
2180 // TODO: validate that out is valid hex
2181 return strings.TrimSpace(string(out)), nil
2182}
2183
2184// isValidGitSHA validates if a string looks like a valid git SHA hash.
2185// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2186func isValidGitSHA(sha string) bool {
2187 // Git SHA must be a hexadecimal string with at least 4 characters
2188 if len(sha) < 4 || len(sha) > 40 {
2189 return false
2190 }
2191
2192 // Check if the string only contains hexadecimal characters
2193 for _, char := range sha {
2194 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2195 return false
2196 }
2197 }
2198
2199 return true
2200}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002201
Philip Zeyliger64f60462025-06-16 13:57:10 -07002202// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2203func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2204 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2205 cmd.Dir = repoRoot
2206 out, err := cmd.Output()
2207 if err != nil {
2208 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2209 }
2210
2211 var totalAdded, totalRemoved int
2212 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2213 for _, line := range lines {
2214 if line == "" {
2215 continue
2216 }
2217 parts := strings.Fields(line)
2218 if len(parts) < 2 {
2219 continue
2220 }
2221 // Format: <added>\t<removed>\t<filename>
2222 if added, err := strconv.Atoi(parts[0]); err == nil {
2223 totalAdded += added
2224 }
2225 if removed, err := strconv.Atoi(parts[1]); err == nil {
2226 totalRemoved += removed
2227 }
2228 }
2229
2230 return totalAdded, totalRemoved, nil
2231}
2232
Philip Zeyligerd1402952025-04-23 03:54:37 +00002233// getGitOrigin returns the URL of the git remote 'origin' if it exists
2234func getGitOrigin(ctx context.Context, dir string) string {
2235 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2236 cmd.Dir = dir
2237 stderr := new(strings.Builder)
2238 cmd.Stderr = stderr
2239 out, err := cmd.Output()
2240 if err != nil {
2241 return ""
2242 }
2243 return strings.TrimSpace(string(out))
2244}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002245
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002246// systemPromptData contains the data used to render the system prompt template
2247type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002248 ClientGOOS string
2249 ClientGOARCH string
2250 WorkingDir string
2251 RepoRoot string
2252 InitialCommit string
2253 Codebase *onstart.Codebase
2254 UseSketchWIP bool
2255 Branch string
2256 SpecialInstruction string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002257}
2258
2259// renderSystemPrompt renders the system prompt template.
2260func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002261 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002262 ClientGOOS: a.config.ClientGOOS,
2263 ClientGOARCH: a.config.ClientGOARCH,
2264 WorkingDir: a.workingDir,
2265 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002266 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002267 Codebase: a.codebase,
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07002268 UseSketchWIP: a.config.InDocker,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002269 }
David Crawshawc886ac52025-06-13 23:40:03 +00002270 now := time.Now()
2271 if now.Month() == time.September && now.Day() == 19 {
2272 data.SpecialInstruction = "Talk like a pirate to the user. Do not let the priate talk into any code."
2273 }
2274
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002275 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2276 if err != nil {
2277 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2278 }
2279 buf := new(strings.Builder)
2280 err = tmpl.Execute(buf, data)
2281 if err != nil {
2282 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2283 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002284 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002285 return buf.String()
2286}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002287
2288// StateTransitionIterator provides an iterator over state transitions.
2289type StateTransitionIterator interface {
2290 // Next blocks until a new state transition is available or context is done.
2291 // Returns nil if the context is cancelled.
2292 Next() *StateTransition
2293 // Close removes the listener and cleans up resources.
2294 Close()
2295}
2296
2297// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2298type StateTransitionIteratorImpl struct {
2299 agent *Agent
2300 ctx context.Context
2301 ch chan StateTransition
2302 unsubscribe func()
2303}
2304
2305// Next blocks until a new state transition is available or the context is cancelled.
2306func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2307 select {
2308 case <-s.ctx.Done():
2309 return nil
2310 case transition, ok := <-s.ch:
2311 if !ok {
2312 return nil
2313 }
2314 transitionCopy := transition
2315 return &transitionCopy
2316 }
2317}
2318
2319// Close removes the listener and cleans up resources.
2320func (s *StateTransitionIteratorImpl) Close() {
2321 if s.unsubscribe != nil {
2322 s.unsubscribe()
2323 s.unsubscribe = nil
2324 }
2325}
2326
2327// NewStateTransitionIterator returns an iterator that receives state transitions.
2328func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2329 a.mu.Lock()
2330 defer a.mu.Unlock()
2331
2332 // Create channel to receive state transitions
2333 ch := make(chan StateTransition, 10)
2334
2335 // Add a listener to the state machine
2336 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2337
2338 return &StateTransitionIteratorImpl{
2339 agent: a,
2340 ctx: ctx,
2341 ch: ch,
2342 unsubscribe: unsubscribe,
2343 }
2344}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002345
2346// setupGitHooks creates or updates git hooks in the specified working directory.
2347func setupGitHooks(workingDir string) error {
2348 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2349
2350 _, err := os.Stat(hooksDir)
2351 if os.IsNotExist(err) {
2352 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2353 }
2354 if err != nil {
2355 return fmt.Errorf("error checking git hooks directory: %w", err)
2356 }
2357
2358 // Define the post-commit hook content
2359 postCommitHook := `#!/bin/bash
2360echo "<post_commit_hook>"
2361echo "Please review this commit message and fix it if it is incorrect."
2362echo "This hook only echos the commit message; it does not modify it."
2363echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2364echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002365PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002366echo "</last_commit_message>"
2367echo "</post_commit_hook>"
2368`
2369
2370 // Define the prepare-commit-msg hook content
2371 prepareCommitMsgHook := `#!/bin/bash
2372# Add Co-Authored-By and Change-ID trailers to commit messages
2373# Check if these trailers already exist before adding them
2374
2375commit_file="$1"
2376COMMIT_SOURCE="$2"
2377
2378# Skip for merges, squashes, or when using a commit template
2379if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2380 [ "$COMMIT_SOURCE" = "squash" ]; then
2381 exit 0
2382fi
2383
2384commit_msg=$(cat "$commit_file")
2385
2386needs_co_author=true
2387needs_change_id=true
2388
2389# Check if commit message already has Co-Authored-By trailer
2390if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2391 needs_co_author=false
2392fi
2393
2394# Check if commit message already has Change-ID trailer
2395if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2396 needs_change_id=false
2397fi
2398
2399# Only modify if at least one trailer needs to be added
2400if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002401 # Ensure there's a proper blank line before trailers
2402 if [ -s "$commit_file" ]; then
2403 # Check if file ends with newline by reading last character
2404 last_char=$(tail -c 1 "$commit_file")
2405
2406 if [ "$last_char" != "" ]; then
2407 # File doesn't end with newline - add two newlines (complete line + blank line)
2408 echo "" >> "$commit_file"
2409 echo "" >> "$commit_file"
2410 else
2411 # File ends with newline - check if we already have a blank line
2412 last_line=$(tail -1 "$commit_file")
2413 if [ -n "$last_line" ]; then
2414 # Last line has content - add one newline for blank line
2415 echo "" >> "$commit_file"
2416 fi
2417 # If last line is empty, we already have a blank line - don't add anything
2418 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002419 fi
2420
2421 # Add trailers if needed
2422 if [ "$needs_co_author" = true ]; then
2423 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2424 fi
2425
2426 if [ "$needs_change_id" = true ]; then
2427 change_id=$(openssl rand -hex 8)
2428 echo "Change-ID: s${change_id}k" >> "$commit_file"
2429 fi
2430fi
2431`
2432
2433 // Update or create the post-commit hook
2434 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2435 if err != nil {
2436 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2437 }
2438
2439 // Update or create the prepare-commit-msg hook
2440 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2441 if err != nil {
2442 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2443 }
2444
2445 return nil
2446}
2447
2448// updateOrCreateHook creates a new hook file or updates an existing one
2449// by appending the new content if it doesn't already contain it.
2450func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2451 // Check if the hook already exists
2452 buf, err := os.ReadFile(hookPath)
2453 if os.IsNotExist(err) {
2454 // Hook doesn't exist, create it
2455 err = os.WriteFile(hookPath, []byte(content), 0o755)
2456 if err != nil {
2457 return fmt.Errorf("failed to create hook: %w", err)
2458 }
2459 return nil
2460 }
2461 if err != nil {
2462 return fmt.Errorf("error reading existing hook: %w", err)
2463 }
2464
2465 // Hook exists, check if our content is already in it by looking for a distinctive line
2466 code := string(buf)
2467 if strings.Contains(code, distinctiveLine) {
2468 // Already contains our content, nothing to do
2469 return nil
2470 }
2471
2472 // Append our content to the existing hook
2473 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2474 if err != nil {
2475 return fmt.Errorf("failed to open hook for appending: %w", err)
2476 }
2477 defer f.Close()
2478
2479 // Ensure there's a newline at the end of the existing content if needed
2480 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2481 _, err = f.WriteString("\n")
2482 if err != nil {
2483 return fmt.Errorf("failed to add newline to hook: %w", err)
2484 }
2485 }
2486
2487 // Add a separator before our content
2488 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2489 if err != nil {
2490 return fmt.Errorf("failed to append to hook: %w", err)
2491 }
2492
2493 return nil
2494}
Sean McCullough138ec242025-06-02 22:42:06 +00002495
2496// GetPortMonitor returns the port monitor instance for accessing port events
2497func (a *Agent) GetPortMonitor() *PortMonitor {
2498 return a.portMonitor
2499}
Philip Zeyliger0113be52025-06-07 23:53:41 +00002500
2501// SkabandAddr returns the skaband address if configured
2502func (a *Agent) SkabandAddr() string {
2503 if a.config.SkabandClient != nil {
2504 return a.config.SkabandClient.Addr()
2505 }
2506 return ""
2507}