blob: 5c36bcfd2d870c9266fd4022e690484eeeb382eb [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 Zeyligerc17ffe32025-06-05 19:49:13 -070031 "sketch.dev/skabandclient"
Earl Lee2e463fb2025-04-17 11:22:22 -070032)
33
34const (
35 userCancelMessage = "user requested agent to stop handling responses"
36)
37
Philip Zeyligerb7c58752025-05-01 10:10:17 -070038type MessageIterator interface {
39 // Next blocks until the next message is available. It may
40 // return nil if the underlying iterator context is done.
41 Next() *AgentMessage
42 Close()
43}
44
Earl Lee2e463fb2025-04-17 11:22:22 -070045type CodingAgent interface {
46 // Init initializes an agent inside a docker container.
47 Init(AgentInit) error
48
49 // Ready returns a channel closed after Init successfully called.
50 Ready() <-chan struct{}
51
52 // URL reports the HTTP URL of this agent.
53 URL() string
54
55 // UserMessage enqueues a message to the agent and returns immediately.
56 UserMessage(ctx context.Context, msg string)
57
Philip Zeyligerb7c58752025-05-01 10:10:17 -070058 // Returns an iterator that finishes when the context is done and
59 // starts with the given message index.
60 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070061
Philip Zeyligereab12de2025-05-14 02:35:53 +000062 // Returns an iterator that notifies of state transitions until the context is done.
63 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
64
Earl Lee2e463fb2025-04-17 11:22:22 -070065 // Loop begins the agent loop returns only when ctx is cancelled.
66 Loop(ctx context.Context)
67
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000068 // BranchPrefix returns the configured branch prefix
69 BranchPrefix() string
70
philip.zeyliger6d3de482025-06-10 19:38:14 -070071 // LinkToGitHub returns whether GitHub branch linking is enabled
72 LinkToGitHub() bool
73
Sean McCulloughedc88dc2025-04-30 02:55:01 +000074 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070075
76 CancelToolUse(toolUseID string, cause error) error
77
78 // Returns a subset of the agent's message history.
79 Messages(start int, end int) []AgentMessage
80
81 // Returns the current number of messages in the history
82 MessageCount() int
83
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070084 TotalUsage() conversation.CumulativeUsage
85 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070086
Earl Lee2e463fb2025-04-17 11:22:22 -070087 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000088 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070089
90 // Diff returns a unified diff of changes made since the agent was instantiated.
91 // If commit is non-nil, it shows the diff for just that specific commit.
92 Diff(commit *string) (string, error)
93
Philip Zeyliger49edc922025-05-14 09:45:45 -070094 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
95 // starts out as the commit where sketch started, but a user can move it if need
96 // be, for example in the case of a rebase. It is stored as a git tag.
97 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070098
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000099 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
100 // (Typically, this is "sketch-base")
101 SketchGitBaseRef() string
102
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700103 // Slug returns the slug identifier for this session.
104 Slug() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700105
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000106 // BranchName returns the git branch name for the conversation.
107 BranchName() string
108
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700109 // IncrementRetryNumber increments the retry number for branch naming conflicts.
110 IncrementRetryNumber()
111
Earl Lee2e463fb2025-04-17 11:22:22 -0700112 // OS returns the operating system of the client.
113 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000114
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000115 // SessionID returns the unique session identifier.
116 SessionID() string
117
philip.zeyliger8773e682025-06-11 21:36:21 -0700118 // SSHConnectionString returns the SSH connection string for the container.
119 SSHConnectionString() string
120
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000121 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700122 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000123
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000124 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
125 OutstandingLLMCallCount() int
126
127 // OutstandingToolCalls returns the names of outstanding tool calls.
128 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000129 OutsideOS() string
130 OutsideHostname() string
131 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000132 GitOrigin() string
Philip Zeyliger64f60462025-06-16 13:57:10 -0700133
134 // DiffStats returns the number of lines added and removed from sketch-base to HEAD
135 DiffStats() (int, int)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000136 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
137 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700138
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700139 // IsInContainer returns true if the agent is running in a container
140 IsInContainer() bool
141 // FirstMessageIndex returns the index of the first message in the current conversation
142 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700143
144 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700145 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
146 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700147
148 // CompactConversation compacts the current conversation by generating a summary
149 // and restarting the conversation with that summary as the initial context
150 CompactConversation(ctx context.Context) error
Sean McCullough138ec242025-06-02 22:42:06 +0000151 // GetPortMonitor returns the port monitor instance for accessing port events
152 GetPortMonitor() *PortMonitor
Philip Zeyliger0113be52025-06-07 23:53:41 +0000153 // SkabandAddr returns the skaband address if configured
154 SkabandAddr() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700155}
156
157type CodingAgentMessageType string
158
159const (
160 UserMessageType CodingAgentMessageType = "user"
161 AgentMessageType CodingAgentMessageType = "agent"
162 ErrorMessageType CodingAgentMessageType = "error"
163 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
164 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700165 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
166 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
167 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700168
169 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
170)
171
172type AgentMessage struct {
173 Type CodingAgentMessageType `json:"type"`
174 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
175 EndOfTurn bool `json:"end_of_turn"`
176
177 Content string `json:"content"`
178 ToolName string `json:"tool_name,omitempty"`
179 ToolInput string `json:"input,omitempty"`
180 ToolResult string `json:"tool_result,omitempty"`
181 ToolError bool `json:"tool_error,omitempty"`
182 ToolCallId string `json:"tool_call_id,omitempty"`
183
184 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
185 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
186
Sean McCulloughd9f13372025-04-21 15:08:49 -0700187 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
188 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
189
Earl Lee2e463fb2025-04-17 11:22:22 -0700190 // Commits is a list of git commits for a commit message
191 Commits []*GitCommit `json:"commits,omitempty"`
192
193 Timestamp time.Time `json:"timestamp"`
194 ConversationID string `json:"conversation_id"`
195 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700196 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700197
198 // Message timing information
199 StartTime *time.Time `json:"start_time,omitempty"`
200 EndTime *time.Time `json:"end_time,omitempty"`
201 Elapsed *time.Duration `json:"elapsed,omitempty"`
202
203 // Turn duration - the time taken for a complete agent turn
204 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
205
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000206 // HideOutput indicates that this message should not be rendered in the UI.
207 // This is useful for subconversations that generate output that shouldn't be shown to the user.
208 HideOutput bool `json:"hide_output,omitempty"`
209
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700210 // TodoContent contains the agent's todo file content when it has changed
211 TodoContent *string `json:"todo_content,omitempty"`
212
Earl Lee2e463fb2025-04-17 11:22:22 -0700213 Idx int `json:"idx"`
214}
215
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000216// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700217func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700218 if convo == nil {
219 m.ConversationID = ""
220 m.ParentConversationID = nil
221 return
222 }
223 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000224 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700225 if convo.Parent != nil {
226 m.ParentConversationID = &convo.Parent.ID
227 }
228}
229
Earl Lee2e463fb2025-04-17 11:22:22 -0700230// GitCommit represents a single git commit for a commit message
231type GitCommit struct {
232 Hash string `json:"hash"` // Full commit hash
233 Subject string `json:"subject"` // Commit subject line
234 Body string `json:"body"` // Full commit message body
235 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
236}
237
238// ToolCall represents a single tool call within an agent message
239type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700240 Name string `json:"name"`
241 Input string `json:"input"`
242 ToolCallId string `json:"tool_call_id"`
243 ResultMessage *AgentMessage `json:"result_message,omitempty"`
244 Args string `json:"args,omitempty"`
245 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700246}
247
248func (a *AgentMessage) Attr() slog.Attr {
249 var attrs []any = []any{
250 slog.String("type", string(a.Type)),
251 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700252 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700253 if a.EndOfTurn {
254 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
255 }
256 if a.Content != "" {
257 attrs = append(attrs, slog.String("content", a.Content))
258 }
259 if a.ToolName != "" {
260 attrs = append(attrs, slog.String("tool_name", a.ToolName))
261 }
262 if a.ToolInput != "" {
263 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
264 }
265 if a.Elapsed != nil {
266 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
267 }
268 if a.TurnDuration != nil {
269 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
270 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700271 if len(a.ToolResult) > 0 {
272 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700273 }
274 if a.ToolError {
275 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
276 }
277 if len(a.ToolCalls) > 0 {
278 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
279 for i, tc := range a.ToolCalls {
280 toolCallAttrs = append(toolCallAttrs, slog.Group(
281 fmt.Sprintf("tool_call_%d", i),
282 slog.String("name", tc.Name),
283 slog.String("input", tc.Input),
284 ))
285 }
286 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
287 }
288 if a.ConversationID != "" {
289 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
290 }
291 if a.ParentConversationID != nil {
292 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
293 }
294 if a.Usage != nil && !a.Usage.IsZero() {
295 attrs = append(attrs, a.Usage.Attr())
296 }
297 // TODO: timestamp, convo ids, idx?
298 return slog.Group("agent_message", attrs...)
299}
300
301func errorMessage(err error) AgentMessage {
302 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
303 if os.Getenv(("DEBUG")) == "1" {
304 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
305 }
306
307 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
308}
309
310func budgetMessage(err error) AgentMessage {
311 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
312}
313
314// ConvoInterface defines the interface for conversation interactions
315type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700316 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700317 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700318 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700319 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700320 SendMessage(message llm.Message) (*llm.Response, error)
321 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700322 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000323 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700324 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700325 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700326 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700327}
328
Philip Zeyligerf2872992025-05-22 10:35:28 -0700329// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700330// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700331// any time we notice we need to.
332type AgentGitState struct {
333 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700334 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700335 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000336 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700337 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700338 slug string // Human-readable session identifier
339 retryNumber int // Number to append when branch conflicts occur
Philip Zeyliger64f60462025-06-16 13:57:10 -0700340 linesAdded int // Lines added from sketch-base to HEAD
341 linesRemoved int // Lines removed from sketch-base to HEAD
Philip Zeyligerf2872992025-05-22 10:35:28 -0700342}
343
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700344func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700345 ags.mu.Lock()
346 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700347 if ags.slug != slug {
348 ags.retryNumber = 0
349 }
350 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700351}
352
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700353func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700354 ags.mu.Lock()
355 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700356 return ags.slug
357}
358
359func (ags *AgentGitState) IncrementRetryNumber() {
360 ags.mu.Lock()
361 defer ags.mu.Unlock()
362 ags.retryNumber++
363}
364
Philip Zeyliger64f60462025-06-16 13:57:10 -0700365func (ags *AgentGitState) DiffStats() (int, int) {
366 ags.mu.Lock()
367 defer ags.mu.Unlock()
368 return ags.linesAdded, ags.linesRemoved
369}
370
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700371// HasSeenCommits returns true if any commits have been processed
372func (ags *AgentGitState) HasSeenCommits() bool {
373 ags.mu.Lock()
374 defer ags.mu.Unlock()
375 return len(ags.seenCommits) > 0
376}
377
378func (ags *AgentGitState) RetryNumber() int {
379 ags.mu.Lock()
380 defer ags.mu.Unlock()
381 return ags.retryNumber
382}
383
384func (ags *AgentGitState) BranchName(prefix string) string {
385 ags.mu.Lock()
386 defer ags.mu.Unlock()
387 return ags.branchNameLocked(prefix)
388}
389
390func (ags *AgentGitState) branchNameLocked(prefix string) string {
391 if ags.slug == "" {
392 return ""
393 }
394 if ags.retryNumber == 0 {
395 return prefix + ags.slug
396 }
397 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700398}
399
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000400func (ags *AgentGitState) Upstream() string {
401 ags.mu.Lock()
402 defer ags.mu.Unlock()
403 return ags.upstream
404}
405
Earl Lee2e463fb2025-04-17 11:22:22 -0700406type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700407 convo ConvoInterface
408 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700409 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700410 workingDir string
411 repoRoot string // workingDir may be a subdir of repoRoot
412 url string
413 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000414 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700415 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000416 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700417 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700418 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000419 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700420 // State machine to track agent state
421 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000422 // Outside information
423 outsideHostname string
424 outsideOS string
425 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000426 // URL of the git remote 'origin' if it exists
427 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700428
429 // Time when the current turn started (reset at the beginning of InnerLoop)
430 startOfTurn time.Time
431
432 // Inbox - for messages from the user to the agent.
433 // sent on by UserMessage
434 // . e.g. when user types into the chat textarea
435 // read from by GatherMessages
436 inbox chan string
437
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000438 // protects cancelTurn
439 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700440 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000441 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700442
443 // protects following
444 mu sync.Mutex
445
446 // Stores all messages for this agent
447 history []AgentMessage
448
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700449 // Iterators add themselves here when they're ready to be notified of new messages.
450 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700451
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000452 // Track outstanding LLM call IDs
453 outstandingLLMCalls map[string]struct{}
454
455 // Track outstanding tool calls by ID with their names
456 outstandingToolCalls map[string]string
Sean McCullough364f7412025-06-02 00:55:44 +0000457
458 // Port monitoring
459 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700460}
461
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700462// NewIterator implements CodingAgent.
463func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
464 a.mu.Lock()
465 defer a.mu.Unlock()
466
467 return &MessageIteratorImpl{
468 agent: a,
469 ctx: ctx,
470 nextMessageIdx: nextMessageIdx,
471 ch: make(chan *AgentMessage, 100),
472 }
473}
474
475type MessageIteratorImpl struct {
476 agent *Agent
477 ctx context.Context
478 nextMessageIdx int
479 ch chan *AgentMessage
480 subscribed bool
481}
482
483func (m *MessageIteratorImpl) Close() {
484 m.agent.mu.Lock()
485 defer m.agent.mu.Unlock()
486 // Delete ourselves from the subscribers list
487 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
488 return x == m.ch
489 })
490 close(m.ch)
491}
492
493func (m *MessageIteratorImpl) Next() *AgentMessage {
494 // We avoid subscription at creation to let ourselves catch up to "current state"
495 // before subscribing.
496 if !m.subscribed {
497 m.agent.mu.Lock()
498 if m.nextMessageIdx < len(m.agent.history) {
499 msg := &m.agent.history[m.nextMessageIdx]
500 m.nextMessageIdx++
501 m.agent.mu.Unlock()
502 return msg
503 }
504 // The next message doesn't exist yet, so let's subscribe
505 m.agent.subscribers = append(m.agent.subscribers, m.ch)
506 m.subscribed = true
507 m.agent.mu.Unlock()
508 }
509
510 for {
511 select {
512 case <-m.ctx.Done():
513 m.agent.mu.Lock()
514 // Delete ourselves from the subscribers list
515 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
516 return x == m.ch
517 })
518 m.subscribed = false
519 m.agent.mu.Unlock()
520 return nil
521 case msg, ok := <-m.ch:
522 if !ok {
523 // Close may have been called
524 return nil
525 }
526 if msg.Idx == m.nextMessageIdx {
527 m.nextMessageIdx++
528 return msg
529 }
530 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
531 panic("out of order message")
532 }
533 }
534}
535
Sean McCulloughd9d45812025-04-30 16:53:41 -0700536// Assert that Agent satisfies the CodingAgent interface.
537var _ CodingAgent = &Agent{}
538
539// StateName implements CodingAgent.
540func (a *Agent) CurrentStateName() string {
541 if a.stateMachine == nil {
542 return ""
543 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000544 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700545}
546
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700547// CurrentTodoContent returns the current todo list data as JSON.
548// It returns an empty string if no todos exist.
549func (a *Agent) CurrentTodoContent() string {
550 todoPath := claudetool.TodoFilePath(a.config.SessionID)
551 content, err := os.ReadFile(todoPath)
552 if err != nil {
553 return ""
554 }
555 return string(content)
556}
557
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700558// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
559func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
560 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.
561
562IMPORTANT: 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.
563
564Please create a detailed summary that includes:
565
5661. **User's Request**: What did the user originally ask me to do? What was their goal?
567
5682. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
569
5703. **Key Technical Decisions**: What important technical choices were made during our work and why?
571
5724. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
573
5745. **Next Steps**: What still needs to be done to complete the user's request?
575
5766. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
577
578Focus 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.
579
580Reply with ONLY the summary content - no meta-commentary about creating the summary.`
581
582 userMessage := llm.UserStringMessage(msg)
583 // Use a subconversation with history to get the summary
584 // TODO: We don't have any tools here, so we should have enough tokens
585 // to capture a summary, but we may need to modify the history (e.g., remove
586 // TODO data) to save on some tokens.
587 convo := a.convo.SubConvoWithHistory()
588
589 // Modify the system prompt to provide context about the original task
590 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000591 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 -0700592
593Your 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.
594
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000595Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700596
597 resp, err := convo.SendMessage(userMessage)
598 if err != nil {
599 a.pushToOutbox(ctx, errorMessage(err))
600 return "", err
601 }
602 textContent := collectTextContent(resp)
603
604 // Restore original system prompt (though this subconvo will be discarded)
605 convo.SystemPrompt = originalSystemPrompt
606
607 return textContent, nil
608}
609
610// CompactConversation compacts the current conversation by generating a summary
611// and restarting the conversation with that summary as the initial context
612func (a *Agent) CompactConversation(ctx context.Context) error {
613 summary, err := a.generateConversationSummary(ctx)
614 if err != nil {
615 return fmt.Errorf("failed to generate conversation summary: %w", err)
616 }
617
618 a.mu.Lock()
619
620 // Get usage information before resetting conversation
621 lastUsage := a.convo.LastUsage()
622 contextWindow := a.config.Service.TokenContextWindow()
623 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
624
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000625 // Preserve cumulative usage across compaction
626 cumulativeUsage := a.convo.CumulativeUsage()
627
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700628 // Reset conversation state but keep all other state (git, working dir, etc.)
629 a.firstMessageIndex = len(a.history)
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000630 a.convo = a.initConvoWithUsage(&cumulativeUsage)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700631
632 a.mu.Unlock()
633
634 // Create informative compaction message with token details
635 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
636 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
637 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
638
639 a.pushToOutbox(ctx, AgentMessage{
640 Type: CompactMessageType,
641 Content: compactionMsg,
642 })
643
644 a.pushToOutbox(ctx, AgentMessage{
645 Type: UserMessageType,
646 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),
647 })
648 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)
649
650 return nil
651}
652
Earl Lee2e463fb2025-04-17 11:22:22 -0700653func (a *Agent) URL() string { return a.url }
654
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000655// BranchName returns the git branch name for the conversation.
656func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700657 return a.gitState.BranchName(a.config.BranchPrefix)
658}
659
660// Slug returns the slug identifier for this conversation.
661func (a *Agent) Slug() string {
662 return a.gitState.Slug()
663}
664
665// IncrementRetryNumber increments the retry number for branch naming conflicts
666func (a *Agent) IncrementRetryNumber() {
667 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000668}
669
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000670// OutstandingLLMCallCount returns the number of outstanding LLM calls.
671func (a *Agent) OutstandingLLMCallCount() int {
672 a.mu.Lock()
673 defer a.mu.Unlock()
674 return len(a.outstandingLLMCalls)
675}
676
677// OutstandingToolCalls returns the names of outstanding tool calls.
678func (a *Agent) OutstandingToolCalls() []string {
679 a.mu.Lock()
680 defer a.mu.Unlock()
681
682 tools := make([]string, 0, len(a.outstandingToolCalls))
683 for _, toolName := range a.outstandingToolCalls {
684 tools = append(tools, toolName)
685 }
686 return tools
687}
688
Earl Lee2e463fb2025-04-17 11:22:22 -0700689// OS returns the operating system of the client.
690func (a *Agent) OS() string {
691 return a.config.ClientGOOS
692}
693
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000694func (a *Agent) SessionID() string {
695 return a.config.SessionID
696}
697
philip.zeyliger8773e682025-06-11 21:36:21 -0700698// SSHConnectionString returns the SSH connection string for the container.
699func (a *Agent) SSHConnectionString() string {
700 return a.config.SSHConnectionString
701}
702
Philip Zeyliger18532b22025-04-23 21:11:46 +0000703// OutsideOS returns the operating system of the outside system.
704func (a *Agent) OutsideOS() string {
705 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000706}
707
Philip Zeyliger18532b22025-04-23 21:11:46 +0000708// OutsideHostname returns the hostname of the outside system.
709func (a *Agent) OutsideHostname() string {
710 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000711}
712
Philip Zeyliger18532b22025-04-23 21:11:46 +0000713// OutsideWorkingDir returns the working directory on the outside system.
714func (a *Agent) OutsideWorkingDir() string {
715 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000716}
717
718// GitOrigin returns the URL of the git remote 'origin' if it exists.
719func (a *Agent) GitOrigin() string {
720 return a.gitOrigin
721}
722
Philip Zeyliger64f60462025-06-16 13:57:10 -0700723// DiffStats returns the number of lines added and removed from sketch-base to HEAD
724func (a *Agent) DiffStats() (int, int) {
725 return a.gitState.DiffStats()
726}
727
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000728func (a *Agent) OpenBrowser(url string) {
729 if !a.IsInContainer() {
730 browser.Open(url)
731 return
732 }
733 // We're in Docker, need to send a request to the Git server
734 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700735 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000736 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700737 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000738 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700739 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000740 return
741 }
742 defer resp.Body.Close()
743 if resp.StatusCode == http.StatusOK {
744 return
745 }
746 body, _ := io.ReadAll(resp.Body)
747 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
748}
749
Sean McCullough96b60dd2025-04-30 09:49:10 -0700750// CurrentState returns the current state of the agent's state machine.
751func (a *Agent) CurrentState() State {
752 return a.stateMachine.CurrentState()
753}
754
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700755func (a *Agent) IsInContainer() bool {
756 return a.config.InDocker
757}
758
759func (a *Agent) FirstMessageIndex() int {
760 a.mu.Lock()
761 defer a.mu.Unlock()
762 return a.firstMessageIndex
763}
764
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700765// SetSlug sets a human-readable identifier for the conversation.
766func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700767 a.mu.Lock()
768 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700769
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700770 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000771 convo, ok := a.convo.(*conversation.Convo)
772 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700773 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000774 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700775}
776
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000777// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700778func (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 +0000779 // Track the tool call
780 a.mu.Lock()
781 a.outstandingToolCalls[id] = toolName
782 a.mu.Unlock()
783}
784
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700785// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
786// If there's only one element in the array and it's a text type, it returns that text directly.
787// It also processes nested ToolResult arrays recursively.
788func contentToString(contents []llm.Content) string {
789 if len(contents) == 0 {
790 return ""
791 }
792
793 // If there's only one element and it's a text type, return it directly
794 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
795 return contents[0].Text
796 }
797
798 // Otherwise, concatenate all text content
799 var result strings.Builder
800 for _, content := range contents {
801 if content.Type == llm.ContentTypeText {
802 result.WriteString(content.Text)
803 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
804 // Recursively process nested tool results
805 result.WriteString(contentToString(content.ToolResult))
806 }
807 }
808
809 return result.String()
810}
811
Earl Lee2e463fb2025-04-17 11:22:22 -0700812// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700813func (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 +0000814 // Remove the tool call from outstanding calls
815 a.mu.Lock()
816 delete(a.outstandingToolCalls, toolID)
817 a.mu.Unlock()
818
Earl Lee2e463fb2025-04-17 11:22:22 -0700819 m := AgentMessage{
820 Type: ToolUseMessageType,
821 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700822 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700823 ToolError: content.ToolError,
824 ToolName: toolName,
825 ToolInput: string(toolInput),
826 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700827 StartTime: content.ToolUseStartTime,
828 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700829 }
830
831 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700832 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
833 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700834 m.Elapsed = &elapsed
835 }
836
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700837 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700838 a.pushToOutbox(ctx, m)
839}
840
841// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700842func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000843 a.mu.Lock()
844 defer a.mu.Unlock()
845 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700846 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
847}
848
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700849// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700850// that need to be displayed (as well as tool calls that we send along when
851// they're done). (It would be reasonable to also mention tool calls when they're
852// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700853func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000854 // Remove the LLM call from outstanding calls
855 a.mu.Lock()
856 delete(a.outstandingLLMCalls, id)
857 a.mu.Unlock()
858
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700859 if resp == nil {
860 // LLM API call failed
861 m := AgentMessage{
862 Type: ErrorMessageType,
863 Content: "API call failed, type 'continue' to try again",
864 }
865 m.SetConvo(convo)
866 a.pushToOutbox(ctx, m)
867 return
868 }
869
Earl Lee2e463fb2025-04-17 11:22:22 -0700870 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700871 if convo.Parent == nil { // subconvos never end the turn
872 switch resp.StopReason {
873 case llm.StopReasonToolUse:
874 // Check whether any of the tool calls are for tools that should end the turn
875 ToolSearch:
876 for _, part := range resp.Content {
877 if part.Type != llm.ContentTypeToolUse {
878 continue
879 }
Sean McCullough021557a2025-05-05 23:20:53 +0000880 // Find the tool by name
881 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700882 if tool.Name == part.ToolName {
883 endOfTurn = tool.EndsTurn
884 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000885 }
886 }
Sean McCullough021557a2025-05-05 23:20:53 +0000887 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700888 default:
889 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000890 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700891 }
892 m := AgentMessage{
893 Type: AgentMessageType,
894 Content: collectTextContent(resp),
895 EndOfTurn: endOfTurn,
896 Usage: &resp.Usage,
897 StartTime: resp.StartTime,
898 EndTime: resp.EndTime,
899 }
900
901 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700902 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700903 var toolCalls []ToolCall
904 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700905 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700906 toolCalls = append(toolCalls, ToolCall{
907 Name: part.ToolName,
908 Input: string(part.ToolInput),
909 ToolCallId: part.ID,
910 })
911 }
912 }
913 m.ToolCalls = toolCalls
914 }
915
916 // Calculate the elapsed time if both start and end times are set
917 if resp.StartTime != nil && resp.EndTime != nil {
918 elapsed := resp.EndTime.Sub(*resp.StartTime)
919 m.Elapsed = &elapsed
920 }
921
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700922 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700923 a.pushToOutbox(ctx, m)
924}
925
926// WorkingDir implements CodingAgent.
927func (a *Agent) WorkingDir() string {
928 return a.workingDir
929}
930
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000931// RepoRoot returns the git repository root directory.
932func (a *Agent) RepoRoot() string {
933 return a.repoRoot
934}
935
Earl Lee2e463fb2025-04-17 11:22:22 -0700936// MessageCount implements CodingAgent.
937func (a *Agent) MessageCount() int {
938 a.mu.Lock()
939 defer a.mu.Unlock()
940 return len(a.history)
941}
942
943// Messages implements CodingAgent.
944func (a *Agent) Messages(start int, end int) []AgentMessage {
945 a.mu.Lock()
946 defer a.mu.Unlock()
947 return slices.Clone(a.history[start:end])
948}
949
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700950// ShouldCompact checks if the conversation should be compacted based on token usage
951func (a *Agent) ShouldCompact() bool {
952 // Get the threshold from environment variable, default to 0.94 (94%)
953 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
954 // and a little bit of buffer.)
955 thresholdRatio := 0.94
956 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
957 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
958 thresholdRatio = parsed
959 }
960 }
961
962 // Get the most recent usage to check current context size
963 lastUsage := a.convo.LastUsage()
964
965 if lastUsage.InputTokens == 0 {
966 // No API calls made yet
967 return false
968 }
969
970 // Calculate the current context size from the last API call
971 // This includes all tokens that were part of the input context:
972 // - Input tokens (user messages, system prompt, conversation history)
973 // - Cache read tokens (cached parts of the context)
974 // - Cache creation tokens (new parts being cached)
975 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
976
977 // Get the service's token context window
978 service := a.config.Service
979 contextWindow := service.TokenContextWindow()
980
981 // Calculate threshold
982 threshold := uint64(float64(contextWindow) * thresholdRatio)
983
984 // Check if we've exceeded the threshold
985 return currentContextSize >= threshold
986}
987
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700988func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700989 return a.originalBudget
990}
991
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000992// Upstream returns the upstream branch for git work
993func (a *Agent) Upstream() string {
994 return a.gitState.Upstream()
995}
996
Earl Lee2e463fb2025-04-17 11:22:22 -0700997// AgentConfig contains configuration for creating a new Agent.
998type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +0000999 Context context.Context
1000 Service llm.Service
1001 Budget conversation.Budget
1002 GitUsername string
1003 GitEmail string
1004 SessionID string
1005 ClientGOOS string
1006 ClientGOARCH string
1007 InDocker bool
1008 OneShot bool
1009 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +00001010 // Outside information
1011 OutsideHostname string
1012 OutsideOS string
1013 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001014
1015 // Outtie's HTTP to, e.g., open a browser
1016 OutsideHTTP string
1017 // Outtie's Git server
1018 GitRemoteAddr string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001019 // Upstream branch for git work
1020 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001021 // Commit to checkout from Outtie
1022 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001023 // Prefix for git branches created by sketch
1024 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001025 // LinkToGitHub enables GitHub branch linking in UI
1026 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001027 // SSH connection string for connecting to the container
1028 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001029 // Skaband client for session history (optional)
1030 SkabandClient *skabandclient.SkabandClient
Earl Lee2e463fb2025-04-17 11:22:22 -07001031}
1032
1033// NewAgent creates a new Agent.
1034// It is not usable until Init() is called.
1035func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001036 // Set default branch prefix if not specified
1037 if config.BranchPrefix == "" {
1038 config.BranchPrefix = "sketch/"
1039 }
1040
Earl Lee2e463fb2025-04-17 11:22:22 -07001041 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001042 config: config,
1043 ready: make(chan struct{}),
1044 inbox: make(chan string, 100),
1045 subscribers: make([]chan *AgentMessage, 0),
1046 startedAt: time.Now(),
1047 originalBudget: config.Budget,
1048 gitState: AgentGitState{
1049 seenCommits: make(map[string]bool),
1050 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001051 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001052 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001053 outsideHostname: config.OutsideHostname,
1054 outsideOS: config.OutsideOS,
1055 outsideWorkingDir: config.OutsideWorkingDir,
1056 outstandingLLMCalls: make(map[string]struct{}),
1057 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001058 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001059 workingDir: config.WorkingDir,
1060 outsideHTTP: config.OutsideHTTP,
Sean McCullough364f7412025-06-02 00:55:44 +00001061 portMonitor: NewPortMonitor(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001062 }
1063 return agent
1064}
1065
1066type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001067 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001068
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001069 InDocker bool
1070 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001071}
1072
1073func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001074 if a.convo != nil {
1075 return fmt.Errorf("Agent.Init: already initialized")
1076 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001077 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001078 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001079
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001080 if !ini.NoGit {
1081 // Capture the original origin before we potentially replace it below
1082 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
1083 }
1084
Philip Zeyliger222bf412025-06-04 16:42:58 +00001085 // If a remote git addr was specified, we configure the origin remote
Philip Zeyligerf2872992025-05-22 10:35:28 -07001086 if a.gitState.gitRemoteAddr != "" {
1087 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
Philip Zeyliger222bf412025-06-04 16:42:58 +00001088
1089 // Remove existing origin remote if it exists
1090 cmd := exec.CommandContext(ctx, "git", "remote", "remove", "origin")
Philip Zeyligerf2872992025-05-22 10:35:28 -07001091 cmd.Dir = a.workingDir
1092 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001093 // Ignore error if origin doesn't exist
1094 slog.DebugContext(ctx, "git remote remove origin (ignoring if not exists)", slog.String("output", string(out)))
Philip Zeyligerf2872992025-05-22 10:35:28 -07001095 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001096
1097 // Add the new remote as origin
1098 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", a.gitState.gitRemoteAddr)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001099 cmd.Dir = a.workingDir
1100 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001101 return fmt.Errorf("git remote add origin: %s: %v", out, err)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001102 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001103
Philip Zeyligerf2872992025-05-22 10:35:28 -07001104 }
1105
1106 // If a commit was specified, we fetch and reset to it.
1107 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001108 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1109
Earl Lee2e463fb2025-04-17 11:22:22 -07001110 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001111 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001112 if out, err := cmd.CombinedOutput(); err != nil {
1113 return fmt.Errorf("git stash: %s: %v", out, err)
1114 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001115 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001116 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001117 if out, err := cmd.CombinedOutput(); err != nil {
1118 return fmt.Errorf("git fetch: %s: %w", out, err)
1119 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001120 // The -B resets the branch if it already exists (or creates it if it doesn't)
1121 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001122 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001123 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1124 // Remove git hooks if they exist and retry
1125 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001126 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001127 if _, statErr := os.Stat(hookPath); statErr == nil {
1128 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1129 slog.String("error", err.Error()),
1130 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001131 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001132 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1133 }
1134
1135 // Retry the checkout operation
Philip Zeyliger1417b692025-06-12 11:07:04 -07001136 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001137 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001138 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001139 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 +01001140 }
1141 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001142 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001143 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001144 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001145 } else if a.IsInContainer() {
1146 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1147 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1148 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1149 cmd.Dir = a.workingDir
1150 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1151 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1152 }
1153 } else {
1154 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001155 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001156
1157 if ini.HostAddr != "" {
1158 a.url = "http://" + ini.HostAddr
1159 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001160
1161 if !ini.NoGit {
1162 repoRoot, err := repoRoot(ctx, a.workingDir)
1163 if err != nil {
1164 return fmt.Errorf("repoRoot: %w", err)
1165 }
1166 a.repoRoot = repoRoot
1167
Earl Lee2e463fb2025-04-17 11:22:22 -07001168 if err != nil {
1169 return fmt.Errorf("resolveRef: %w", err)
1170 }
Philip Zeyliger49edc922025-05-14 09:45:45 -07001171
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001172 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001173 if err := setupGitHooks(a.repoRoot); err != nil {
1174 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1175 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001176 }
1177
Philip Zeyliger49edc922025-05-14 09:45:45 -07001178 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1179 cmd.Dir = repoRoot
1180 if out, err := cmd.CombinedOutput(); err != nil {
1181 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1182 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001183
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001184 slog.Info("running codebase analysis")
1185 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1186 if err != nil {
1187 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001188 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001189 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001190
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001191 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001192 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001193 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001194 }
1195 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001196
Earl Lee2e463fb2025-04-17 11:22:22 -07001197 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001198 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001199 a.convo = a.initConvo()
1200 close(a.ready)
1201 return nil
1202}
1203
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001204//go:embed agent_system_prompt.txt
1205var agentSystemPrompt string
1206
Earl Lee2e463fb2025-04-17 11:22:22 -07001207// initConvo initializes the conversation.
1208// It must not be called until all agent fields are initialized,
1209// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001210func (a *Agent) initConvo() *conversation.Convo {
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001211 return a.initConvoWithUsage(nil)
1212}
1213
1214// initConvoWithUsage initializes the conversation with optional preserved usage.
1215func (a *Agent) initConvoWithUsage(usage *conversation.CumulativeUsage) *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001216 ctx := a.config.Context
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001217 convo := conversation.New(ctx, a.config.Service, usage)
Earl Lee2e463fb2025-04-17 11:22:22 -07001218 convo.PromptCaching = true
1219 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001220 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001221 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001222
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001223 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1224 bashPermissionCheck := func(command string) error {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001225 if a.gitState.Slug() != "" {
1226 return nil // branch is set up
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001227 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001228 willCommit, err := bashkit.WillRunGitCommit(command)
1229 if err != nil {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001230 return nil // fail open
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001231 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001232 if willCommit {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001233 return fmt.Errorf("you must use the set-slug tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001234 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001235 return nil
1236 }
1237
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001238 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001239
Earl Lee2e463fb2025-04-17 11:22:22 -07001240 // Register all tools with the conversation
1241 // When adding, removing, or modifying tools here, double-check that the termui tool display
1242 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001243
1244 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001245 _, supportsScreenshots := a.config.Service.(*ant.Service)
1246 var bTools []*llm.Tool
1247 var browserCleanup func()
1248
1249 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1250 // Add cleanup function to context cancel
1251 go func() {
1252 <-a.config.Context.Done()
1253 browserCleanup()
1254 }()
1255 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001256
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001257 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001258 bashTool, claudetool.Keyword, claudetool.Patch,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001259 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.setSlugTool(), a.commitMessageStyleTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001260 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001261 }
1262
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001263 // One-shot mode is non-interactive, multiple choice requires human response
1264 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001265 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001266 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001267
1268 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001269
1270 // Add session history tools if skaband client is available
1271 if a.config.SkabandClient != nil {
1272 sessionHistoryTools := claudetool.CreateSessionHistoryTools(a.config.SkabandClient, a.config.SessionID, a.gitOrigin)
1273 convo.Tools = append(convo.Tools, sessionHistoryTools...)
1274 }
1275
Earl Lee2e463fb2025-04-17 11:22:22 -07001276 convo.Listener = a
1277 return convo
1278}
1279
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001280var multipleChoiceTool = &llm.Tool{
1281 Name: "multiplechoice",
1282 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.",
1283 EndsTurn: true,
1284 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001285 "type": "object",
1286 "description": "The question and a list of answers you would expect the user to choose from.",
1287 "properties": {
1288 "question": {
1289 "type": "string",
1290 "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?'"
1291 },
1292 "responseOptions": {
1293 "type": "array",
1294 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1295 "items": {
1296 "type": "object",
1297 "properties": {
1298 "caption": {
1299 "type": "string",
1300 "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'"
1301 },
1302 "responseText": {
1303 "type": "string",
1304 "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'"
1305 }
1306 },
1307 "required": ["caption", "responseText"]
1308 }
1309 }
1310 },
1311 "required": ["question", "responseOptions"]
1312}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001313 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1314 // The Run logic for "multiplechoice" tool is a no-op on the server.
1315 // The UI will present a list of options for the user to select from,
1316 // and that's it as far as "executing" the tool_use goes.
1317 // When the user *does* select one of the presented options, that
1318 // responseText gets sent as a chat message on behalf of the user.
1319 return llm.TextContent("end your turn and wait for the user to respond"), nil
1320 },
Sean McCullough485afc62025-04-28 14:28:39 -07001321}
1322
1323type MultipleChoiceOption struct {
1324 Caption string `json:"caption"`
1325 ResponseText string `json:"responseText"`
1326}
1327
1328type MultipleChoiceParams struct {
1329 Question string `json:"question"`
1330 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1331}
1332
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001333// branchExists reports whether branchName exists, either locally or in well-known remotes.
1334func branchExists(dir, branchName string) bool {
1335 refs := []string{
1336 "refs/heads/",
1337 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001338 }
1339 for _, ref := range refs {
1340 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1341 cmd.Dir = dir
1342 if cmd.Run() == nil { // exit code 0 means branch exists
1343 return true
1344 }
1345 }
1346 return false
1347}
1348
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001349func (a *Agent) setSlugTool() *llm.Tool {
1350 return &llm.Tool{
1351 Name: "set-slug",
1352 Description: `Set a short slug as an identifier for this conversation.`,
Earl Lee2e463fb2025-04-17 11:22:22 -07001353 InputSchema: json.RawMessage(`{
1354 "type": "object",
1355 "properties": {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001356 "slug": {
Earl Lee2e463fb2025-04-17 11:22:22 -07001357 "type": "string",
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001358 "description": "A 2-3 word alphanumeric hyphenated slug, imperative tense"
Earl Lee2e463fb2025-04-17 11:22:22 -07001359 }
1360 },
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001361 "required": ["slug"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001362}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001363 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001364 var params struct {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001365 Slug string `json:"slug"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001366 }
1367 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001368 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001369 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001370 // Prevent slug changes if there have been git changes
1371 // This lets the agent change its mind about a good slug,
1372 // while ensuring that once a branch has been pushed, it remains stable.
1373 if s := a.Slug(); s != "" && s != params.Slug && a.gitState.HasSeenCommits() {
1374 return nil, fmt.Errorf("slug already set to %q", s)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001375 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001376 if params.Slug == "" {
1377 return nil, fmt.Errorf("slug parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001378 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001379 slug := cleanSlugName(params.Slug)
1380 if slug == "" {
1381 return nil, fmt.Errorf("slug parameter could not be converted to a valid slug")
1382 }
1383 a.SetSlug(slug)
1384 // TODO: do this by a call to outie, rather than semi-guessing from innie
1385 if branchExists(a.workingDir, a.BranchName()) {
1386 return nil, fmt.Errorf("slug %q already exists; please choose a different slug", slug)
1387 }
1388 return llm.TextContent("OK"), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001389 },
1390 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001391}
1392
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001393func (a *Agent) commitMessageStyleTool() *llm.Tool {
1394 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 +00001395 preCommit := &llm.Tool{
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001396 Name: "commit-message-style",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001397 Description: description,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001398 InputSchema: llm.EmptySchema(),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001399 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001400 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1401 if err != nil {
1402 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1403 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001404 return llm.TextContent(styleHint), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001405 },
1406 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001407 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001408}
1409
1410func (a *Agent) Ready() <-chan struct{} {
1411 return a.ready
1412}
1413
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001414// BranchPrefix returns the configured branch prefix
1415func (a *Agent) BranchPrefix() string {
1416 return a.config.BranchPrefix
1417}
1418
philip.zeyliger6d3de482025-06-10 19:38:14 -07001419// LinkToGitHub returns whether GitHub branch linking is enabled
1420func (a *Agent) LinkToGitHub() bool {
1421 return a.config.LinkToGitHub
1422}
1423
Earl Lee2e463fb2025-04-17 11:22:22 -07001424func (a *Agent) UserMessage(ctx context.Context, msg string) {
1425 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1426 a.inbox <- msg
1427}
1428
Earl Lee2e463fb2025-04-17 11:22:22 -07001429func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1430 return a.convo.CancelToolUse(toolUseID, cause)
1431}
1432
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001433func (a *Agent) CancelTurn(cause error) {
1434 a.cancelTurnMu.Lock()
1435 defer a.cancelTurnMu.Unlock()
1436 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001437 // Force state transition to cancelled state
1438 ctx := a.config.Context
1439 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001440 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001441 }
1442}
1443
1444func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001445 // Start port monitoring when the agent loop begins
1446 // Only monitor ports when running in a container
1447 if a.IsInContainer() {
1448 a.portMonitor.Start(ctxOuter)
1449 }
1450
Earl Lee2e463fb2025-04-17 11:22:22 -07001451 for {
1452 select {
1453 case <-ctxOuter.Done():
1454 return
1455 default:
1456 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001457 a.cancelTurnMu.Lock()
1458 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001459 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001460 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001461 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001462 a.cancelTurn = cancel
1463 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001464 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1465 if err != nil {
1466 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1467 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001468 cancel(nil)
1469 }
1470 }
1471}
1472
1473func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1474 if m.Timestamp.IsZero() {
1475 m.Timestamp = time.Now()
1476 }
1477
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001478 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1479 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1480 m.Content = m.ToolResult
1481 }
1482
Earl Lee2e463fb2025-04-17 11:22:22 -07001483 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1484 if m.EndOfTurn && m.Type == AgentMessageType {
1485 turnDuration := time.Since(a.startOfTurn)
1486 m.TurnDuration = &turnDuration
1487 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1488 }
1489
Earl Lee2e463fb2025-04-17 11:22:22 -07001490 a.mu.Lock()
1491 defer a.mu.Unlock()
1492 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001493 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001494 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001495
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001496 // Notify all subscribers
1497 for _, ch := range a.subscribers {
1498 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001499 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001500}
1501
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001502func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1503 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001504 if block {
1505 select {
1506 case <-ctx.Done():
1507 return m, ctx.Err()
1508 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001509 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001510 }
1511 }
1512 for {
1513 select {
1514 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001515 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001516 default:
1517 return m, nil
1518 }
1519 }
1520}
1521
Sean McCullough885a16a2025-04-30 02:49:25 +00001522// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001523func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001524 // Reset the start of turn time
1525 a.startOfTurn = time.Now()
1526
Sean McCullough96b60dd2025-04-30 09:49:10 -07001527 // Transition to waiting for user input state
1528 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1529
Sean McCullough885a16a2025-04-30 02:49:25 +00001530 // Process initial user message
1531 initialResp, err := a.processUserMessage(ctx)
1532 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001533 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001534 return err
1535 }
1536
1537 // Handle edge case where both initialResp and err are nil
1538 if initialResp == nil {
1539 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001540 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1541
Sean McCullough9f4b8082025-04-30 17:34:07 +00001542 a.pushToOutbox(ctx, errorMessage(err))
1543 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001544 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001545
Earl Lee2e463fb2025-04-17 11:22:22 -07001546 // We do this as we go, but let's also do it at the end of the turn
1547 defer func() {
1548 if _, err := a.handleGitCommits(ctx); err != nil {
1549 // Just log the error, don't stop execution
1550 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1551 }
1552 }()
1553
Sean McCullougha1e0e492025-05-01 10:51:08 -07001554 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001555 resp := initialResp
1556 for {
1557 // Check if we are over budget
1558 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001559 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001560 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001561 }
1562
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001563 // Check if we should compact the conversation
1564 if a.ShouldCompact() {
1565 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1566 if err := a.CompactConversation(ctx); err != nil {
1567 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1568 return err
1569 }
1570 // After compaction, end this turn and start fresh
1571 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1572 return nil
1573 }
1574
Sean McCullough885a16a2025-04-30 02:49:25 +00001575 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001576 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001577 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001578 break
1579 }
1580
Sean McCullough96b60dd2025-04-30 09:49:10 -07001581 // Transition to tool use requested state
1582 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1583
Sean McCullough885a16a2025-04-30 02:49:25 +00001584 // Handle tool execution
1585 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1586 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001587 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001588 }
1589
Sean McCullougha1e0e492025-05-01 10:51:08 -07001590 if toolResp == nil {
1591 return fmt.Errorf("cannot continue conversation with a nil tool response")
1592 }
1593
Sean McCullough885a16a2025-04-30 02:49:25 +00001594 // Set the response for the next iteration
1595 resp = toolResp
1596 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001597
1598 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001599}
1600
1601// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001602func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001603 // Wait for at least one message from the user
1604 msgs, err := a.GatherMessages(ctx, true)
1605 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001606 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001607 return nil, err
1608 }
1609
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001610 userMessage := llm.Message{
1611 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001612 Content: msgs,
1613 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001614
Sean McCullough96b60dd2025-04-30 09:49:10 -07001615 // Transition to sending to LLM state
1616 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1617
Sean McCullough885a16a2025-04-30 02:49:25 +00001618 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001619 resp, err := a.convo.SendMessage(userMessage)
1620 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001621 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001622 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001623 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001624 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001625
Sean McCullough96b60dd2025-04-30 09:49:10 -07001626 // Transition to processing LLM response state
1627 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1628
Sean McCullough885a16a2025-04-30 02:49:25 +00001629 return resp, nil
1630}
1631
1632// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001633func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1634 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001635 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001636 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001637
Sean McCullough96b60dd2025-04-30 09:49:10 -07001638 // Transition to checking for cancellation state
1639 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1640
Sean McCullough885a16a2025-04-30 02:49:25 +00001641 // Check if the operation was cancelled by the user
1642 select {
1643 case <-ctx.Done():
1644 // Don't actually run any of the tools, but rather build a response
1645 // for each tool_use message letting the LLM know that user canceled it.
1646 var err error
1647 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001648 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001649 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001650 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001651 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001652 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001653 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001654 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001655 // Transition to running tool state
1656 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1657
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001658 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001659 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001660 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001661
1662 // Execute the tools
1663 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001664 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001665 if ctx.Err() != nil { // e.g. the user canceled the operation
1666 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001667 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001668 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001669 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001670 a.pushToOutbox(ctx, errorMessage(err))
1671 }
1672 }
1673
1674 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001675 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001676 autoqualityMessages := a.processGitChanges(ctx)
1677
1678 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001679 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001680 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001681 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001682 return false, nil
1683 }
1684
1685 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001686 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1687 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001688}
1689
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001690// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001691func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001692 // Check for git commits
1693 _, err := a.handleGitCommits(ctx)
1694 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001695 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001696 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001697 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001698 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001699}
1700
1701// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1702// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001703func (a *Agent) processGitChanges(ctx context.Context) []string {
1704 // Check for git commits after tool execution
1705 newCommits, err := a.handleGitCommits(ctx)
1706 if err != nil {
1707 // Just log the error, don't stop execution
1708 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1709 return nil
1710 }
1711
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001712 // Run mechanical checks if there was exactly one new commit.
1713 if len(newCommits) != 1 {
1714 return nil
1715 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001716 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001717 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1718 msg := a.codereview.RunMechanicalChecks(ctx)
1719 if msg != "" {
1720 a.pushToOutbox(ctx, AgentMessage{
1721 Type: AutoMessageType,
1722 Content: msg,
1723 Timestamp: time.Now(),
1724 })
1725 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001726 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001727
1728 return autoqualityMessages
1729}
1730
1731// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001732func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001733 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001734 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001735 msgs, err := a.GatherMessages(ctx, false)
1736 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001737 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001738 return false, nil
1739 }
1740
1741 // Inject any auto-generated messages from quality checks
1742 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001743 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001744 }
1745
1746 // Handle cancellation by appending a message about it
1747 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001748 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001749 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001750 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001751 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1752 } else if err := a.convo.OverBudget(); err != nil {
1753 // Handle budget issues by appending a message about it
1754 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 -07001755 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001756 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1757 }
1758
1759 // Combine tool results with user messages
1760 results = append(results, msgs...)
1761
1762 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001763 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001764 resp, err := a.convo.SendMessage(llm.Message{
1765 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001766 Content: results,
1767 })
1768 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001769 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001770 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1771 return true, nil // Return true to continue the conversation, but with no response
1772 }
1773
Sean McCullough96b60dd2025-04-30 09:49:10 -07001774 // Transition back to processing LLM response
1775 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1776
Sean McCullough885a16a2025-04-30 02:49:25 +00001777 if cancelled {
1778 return false, nil
1779 }
1780
1781 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001782}
1783
1784func (a *Agent) overBudget(ctx context.Context) error {
1785 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001786 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001787 m := budgetMessage(err)
1788 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001789 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001790 a.convo.ResetBudget(a.originalBudget)
1791 return err
1792 }
1793 return nil
1794}
1795
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001796func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001797 // Collect all text content
1798 var allText strings.Builder
1799 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001800 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001801 if allText.Len() > 0 {
1802 allText.WriteString("\n\n")
1803 }
1804 allText.WriteString(content.Text)
1805 }
1806 }
1807 return allText.String()
1808}
1809
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001810func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001811 a.mu.Lock()
1812 defer a.mu.Unlock()
1813 return a.convo.CumulativeUsage()
1814}
1815
Earl Lee2e463fb2025-04-17 11:22:22 -07001816// Diff returns a unified diff of changes made since the agent was instantiated.
1817func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001818 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001819 return "", fmt.Errorf("no initial commit reference available")
1820 }
1821
1822 // Find the repository root
1823 ctx := context.Background()
1824
1825 // If a specific commit hash is provided, show just that commit's changes
1826 if commit != nil && *commit != "" {
1827 // Validate that the commit looks like a valid git SHA
1828 if !isValidGitSHA(*commit) {
1829 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1830 }
1831
1832 // Get the diff for just this commit
1833 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1834 cmd.Dir = a.repoRoot
1835 output, err := cmd.CombinedOutput()
1836 if err != nil {
1837 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1838 }
1839 return string(output), nil
1840 }
1841
1842 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001843 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001844 cmd.Dir = a.repoRoot
1845 output, err := cmd.CombinedOutput()
1846 if err != nil {
1847 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1848 }
1849
1850 return string(output), nil
1851}
1852
Philip Zeyliger49edc922025-05-14 09:45:45 -07001853// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1854// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1855func (a *Agent) SketchGitBaseRef() string {
1856 if a.IsInContainer() {
1857 return "sketch-base"
1858 } else {
1859 return "sketch-base-" + a.SessionID()
1860 }
1861}
1862
1863// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1864func (a *Agent) SketchGitBase() string {
1865 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1866 cmd.Dir = a.repoRoot
1867 output, err := cmd.CombinedOutput()
1868 if err != nil {
1869 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1870 return "HEAD"
1871 }
1872 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001873}
1874
Pokey Rule7a113622025-05-12 10:58:45 +01001875// removeGitHooks removes the Git hooks directory from the repository
1876func removeGitHooks(_ context.Context, repoPath string) error {
1877 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1878
1879 // Check if hooks directory exists
1880 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1881 // Directory doesn't exist, nothing to do
1882 return nil
1883 }
1884
1885 // Remove the hooks directory
1886 err := os.RemoveAll(hooksDir)
1887 if err != nil {
1888 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1889 }
1890
1891 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001892 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001893 if err != nil {
1894 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1895 }
1896
1897 return nil
1898}
1899
Philip Zeyligerf2872992025-05-22 10:35:28 -07001900func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001901 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001902 for _, msg := range msgs {
1903 a.pushToOutbox(ctx, msg)
1904 }
1905 return commits, error
1906}
1907
Earl Lee2e463fb2025-04-17 11:22:22 -07001908// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001909// under docker, new HEADs are pushed to a branch according to the slug.
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001910func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001911 ags.mu.Lock()
1912 defer ags.mu.Unlock()
1913
1914 msgs := []AgentMessage{}
1915 if repoRoot == "" {
1916 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001917 }
1918
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001919 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07001920 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001921 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001922 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001923 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001924 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001925 }
1926 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001927 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07001928 }()
1929
Philip Zeyliger64f60462025-06-16 13:57:10 -07001930 // Compute diff stats from baseRef to HEAD when HEAD changes
1931 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
1932 // Log error but don't fail the entire operation
1933 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
1934 } else {
1935 // Set diff stats directly since we already hold the mutex
1936 ags.linesAdded = added
1937 ags.linesRemoved = removed
1938 }
1939
Earl Lee2e463fb2025-04-17 11:22:22 -07001940 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1941 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1942 // to the last 100 commits.
1943 var commits []*GitCommit
1944
1945 // Get commits since the initial commit
1946 // Format: <hash>\0<subject>\0<body>\0
1947 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1948 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001949 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 -07001950 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001951 output, err := cmd.Output()
1952 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001953 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001954 }
1955
1956 // Parse git log output and filter out already seen commits
1957 parsedCommits := parseGitLog(string(output))
1958
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001959 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001960
1961 // Filter out commits we've already seen
1962 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001963 if commit.Hash == sketch {
1964 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07001965 }
1966
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001967 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
1968 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07001969 continue
1970 }
1971
1972 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001973 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001974
1975 // Add to our list of new commits
1976 commits = append(commits, &commit)
1977 }
1978
Philip Zeyligerf2872992025-05-22 10:35:28 -07001979 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001980 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001981 // 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 -07001982 sketchCommit = &GitCommit{}
1983 sketchCommit.Hash = sketch
1984 sketchCommit.Subject = "unknown"
1985 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07001986 }
1987
Earl Lee2e463fb2025-04-17 11:22:22 -07001988 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1989 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1990 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001991
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001992 // 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 +00001993 var out []byte
1994 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001995 originalRetryNumber := ags.retryNumber
1996 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001997 for retries := range 10 {
1998 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07001999 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002000 }
2001
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002002 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002003 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002004 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002005 out, err = cmd.CombinedOutput()
2006
2007 if err == nil {
2008 // Success! Break out of the retry loop
2009 break
2010 }
2011
2012 // Check if this is the "refusing to update checked out branch" error
2013 if !strings.Contains(string(out), "refusing to update checked out branch") {
2014 // This is a different error, so don't retry
2015 break
2016 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002017 }
2018
2019 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002020 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002021 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002022 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002023 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002024 if ags.retryNumber != originalRetryNumber {
2025 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002026 msgs = append(msgs, AgentMessage{
2027 Type: AutoMessageType,
2028 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002029 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 +00002030 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002031 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002032 }
2033 }
2034
2035 // If we found new commits, create a message
2036 if len(commits) > 0 {
2037 msg := AgentMessage{
2038 Type: CommitMessageType,
2039 Timestamp: time.Now(),
2040 Commits: commits,
2041 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002042 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002043 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002044 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002045}
2046
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002047func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002048 return strings.Map(func(r rune) rune {
2049 // lowercase
2050 if r >= 'A' && r <= 'Z' {
2051 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002052 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002053 // replace spaces with dashes
2054 if r == ' ' {
2055 return '-'
2056 }
2057 // allow alphanumerics and dashes
2058 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2059 return r
2060 }
2061 return -1
2062 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002063}
2064
2065// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2066// and returns an array of GitCommit structs.
2067func parseGitLog(output string) []GitCommit {
2068 var commits []GitCommit
2069
2070 // No output means no commits
2071 if len(output) == 0 {
2072 return commits
2073 }
2074
2075 // Split by NULL byte
2076 parts := strings.Split(output, "\x00")
2077
2078 // Process in triplets (hash, subject, body)
2079 for i := 0; i < len(parts); i++ {
2080 // Skip empty parts
2081 if parts[i] == "" {
2082 continue
2083 }
2084
2085 // This should be a hash
2086 hash := strings.TrimSpace(parts[i])
2087
2088 // Make sure we have at least a subject part available
2089 if i+1 >= len(parts) {
2090 break // No more parts available
2091 }
2092
2093 // Get the subject
2094 subject := strings.TrimSpace(parts[i+1])
2095
2096 // Get the body if available
2097 body := ""
2098 if i+2 < len(parts) {
2099 body = strings.TrimSpace(parts[i+2])
2100 }
2101
2102 // Skip to the next triplet
2103 i += 2
2104
2105 commits = append(commits, GitCommit{
2106 Hash: hash,
2107 Subject: subject,
2108 Body: body,
2109 })
2110 }
2111
2112 return commits
2113}
2114
2115func repoRoot(ctx context.Context, dir string) (string, error) {
2116 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2117 stderr := new(strings.Builder)
2118 cmd.Stderr = stderr
2119 cmd.Dir = dir
2120 out, err := cmd.Output()
2121 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002122 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002123 }
2124 return strings.TrimSpace(string(out)), nil
2125}
2126
2127func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2128 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2129 stderr := new(strings.Builder)
2130 cmd.Stderr = stderr
2131 cmd.Dir = dir
2132 out, err := cmd.Output()
2133 if err != nil {
2134 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2135 }
2136 // TODO: validate that out is valid hex
2137 return strings.TrimSpace(string(out)), nil
2138}
2139
2140// isValidGitSHA validates if a string looks like a valid git SHA hash.
2141// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2142func isValidGitSHA(sha string) bool {
2143 // Git SHA must be a hexadecimal string with at least 4 characters
2144 if len(sha) < 4 || len(sha) > 40 {
2145 return false
2146 }
2147
2148 // Check if the string only contains hexadecimal characters
2149 for _, char := range sha {
2150 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2151 return false
2152 }
2153 }
2154
2155 return true
2156}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002157
Philip Zeyliger64f60462025-06-16 13:57:10 -07002158// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2159func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2160 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2161 cmd.Dir = repoRoot
2162 out, err := cmd.Output()
2163 if err != nil {
2164 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2165 }
2166
2167 var totalAdded, totalRemoved int
2168 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2169 for _, line := range lines {
2170 if line == "" {
2171 continue
2172 }
2173 parts := strings.Fields(line)
2174 if len(parts) < 2 {
2175 continue
2176 }
2177 // Format: <added>\t<removed>\t<filename>
2178 if added, err := strconv.Atoi(parts[0]); err == nil {
2179 totalAdded += added
2180 }
2181 if removed, err := strconv.Atoi(parts[1]); err == nil {
2182 totalRemoved += removed
2183 }
2184 }
2185
2186 return totalAdded, totalRemoved, nil
2187}
2188
Philip Zeyligerd1402952025-04-23 03:54:37 +00002189// getGitOrigin returns the URL of the git remote 'origin' if it exists
2190func getGitOrigin(ctx context.Context, dir string) string {
2191 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2192 cmd.Dir = dir
2193 stderr := new(strings.Builder)
2194 cmd.Stderr = stderr
2195 out, err := cmd.Output()
2196 if err != nil {
2197 return ""
2198 }
2199 return strings.TrimSpace(string(out))
2200}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002201
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002202// systemPromptData contains the data used to render the system prompt template
2203type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002204 ClientGOOS string
2205 ClientGOARCH string
2206 WorkingDir string
2207 RepoRoot string
2208 InitialCommit string
2209 Codebase *onstart.Codebase
2210 UseSketchWIP bool
2211 Branch string
2212 SpecialInstruction string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002213}
2214
2215// renderSystemPrompt renders the system prompt template.
2216func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002217 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002218 ClientGOOS: a.config.ClientGOOS,
2219 ClientGOARCH: a.config.ClientGOARCH,
2220 WorkingDir: a.workingDir,
2221 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002222 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002223 Codebase: a.codebase,
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07002224 UseSketchWIP: a.config.InDocker,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002225 }
David Crawshawc886ac52025-06-13 23:40:03 +00002226 now := time.Now()
2227 if now.Month() == time.September && now.Day() == 19 {
2228 data.SpecialInstruction = "Talk like a pirate to the user. Do not let the priate talk into any code."
2229 }
2230
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002231 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2232 if err != nil {
2233 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2234 }
2235 buf := new(strings.Builder)
2236 err = tmpl.Execute(buf, data)
2237 if err != nil {
2238 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2239 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002240 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002241 return buf.String()
2242}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002243
2244// StateTransitionIterator provides an iterator over state transitions.
2245type StateTransitionIterator interface {
2246 // Next blocks until a new state transition is available or context is done.
2247 // Returns nil if the context is cancelled.
2248 Next() *StateTransition
2249 // Close removes the listener and cleans up resources.
2250 Close()
2251}
2252
2253// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2254type StateTransitionIteratorImpl struct {
2255 agent *Agent
2256 ctx context.Context
2257 ch chan StateTransition
2258 unsubscribe func()
2259}
2260
2261// Next blocks until a new state transition is available or the context is cancelled.
2262func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2263 select {
2264 case <-s.ctx.Done():
2265 return nil
2266 case transition, ok := <-s.ch:
2267 if !ok {
2268 return nil
2269 }
2270 transitionCopy := transition
2271 return &transitionCopy
2272 }
2273}
2274
2275// Close removes the listener and cleans up resources.
2276func (s *StateTransitionIteratorImpl) Close() {
2277 if s.unsubscribe != nil {
2278 s.unsubscribe()
2279 s.unsubscribe = nil
2280 }
2281}
2282
2283// NewStateTransitionIterator returns an iterator that receives state transitions.
2284func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2285 a.mu.Lock()
2286 defer a.mu.Unlock()
2287
2288 // Create channel to receive state transitions
2289 ch := make(chan StateTransition, 10)
2290
2291 // Add a listener to the state machine
2292 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2293
2294 return &StateTransitionIteratorImpl{
2295 agent: a,
2296 ctx: ctx,
2297 ch: ch,
2298 unsubscribe: unsubscribe,
2299 }
2300}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002301
2302// setupGitHooks creates or updates git hooks in the specified working directory.
2303func setupGitHooks(workingDir string) error {
2304 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2305
2306 _, err := os.Stat(hooksDir)
2307 if os.IsNotExist(err) {
2308 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2309 }
2310 if err != nil {
2311 return fmt.Errorf("error checking git hooks directory: %w", err)
2312 }
2313
2314 // Define the post-commit hook content
2315 postCommitHook := `#!/bin/bash
2316echo "<post_commit_hook>"
2317echo "Please review this commit message and fix it if it is incorrect."
2318echo "This hook only echos the commit message; it does not modify it."
2319echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2320echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002321PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002322echo "</last_commit_message>"
2323echo "</post_commit_hook>"
2324`
2325
2326 // Define the prepare-commit-msg hook content
2327 prepareCommitMsgHook := `#!/bin/bash
2328# Add Co-Authored-By and Change-ID trailers to commit messages
2329# Check if these trailers already exist before adding them
2330
2331commit_file="$1"
2332COMMIT_SOURCE="$2"
2333
2334# Skip for merges, squashes, or when using a commit template
2335if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2336 [ "$COMMIT_SOURCE" = "squash" ]; then
2337 exit 0
2338fi
2339
2340commit_msg=$(cat "$commit_file")
2341
2342needs_co_author=true
2343needs_change_id=true
2344
2345# Check if commit message already has Co-Authored-By trailer
2346if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2347 needs_co_author=false
2348fi
2349
2350# Check if commit message already has Change-ID trailer
2351if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2352 needs_change_id=false
2353fi
2354
2355# Only modify if at least one trailer needs to be added
2356if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002357 # Ensure there's a proper blank line before trailers
2358 if [ -s "$commit_file" ]; then
2359 # Check if file ends with newline by reading last character
2360 last_char=$(tail -c 1 "$commit_file")
2361
2362 if [ "$last_char" != "" ]; then
2363 # File doesn't end with newline - add two newlines (complete line + blank line)
2364 echo "" >> "$commit_file"
2365 echo "" >> "$commit_file"
2366 else
2367 # File ends with newline - check if we already have a blank line
2368 last_line=$(tail -1 "$commit_file")
2369 if [ -n "$last_line" ]; then
2370 # Last line has content - add one newline for blank line
2371 echo "" >> "$commit_file"
2372 fi
2373 # If last line is empty, we already have a blank line - don't add anything
2374 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002375 fi
2376
2377 # Add trailers if needed
2378 if [ "$needs_co_author" = true ]; then
2379 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2380 fi
2381
2382 if [ "$needs_change_id" = true ]; then
2383 change_id=$(openssl rand -hex 8)
2384 echo "Change-ID: s${change_id}k" >> "$commit_file"
2385 fi
2386fi
2387`
2388
2389 // Update or create the post-commit hook
2390 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2391 if err != nil {
2392 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2393 }
2394
2395 // Update or create the prepare-commit-msg hook
2396 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2397 if err != nil {
2398 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2399 }
2400
2401 return nil
2402}
2403
2404// updateOrCreateHook creates a new hook file or updates an existing one
2405// by appending the new content if it doesn't already contain it.
2406func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2407 // Check if the hook already exists
2408 buf, err := os.ReadFile(hookPath)
2409 if os.IsNotExist(err) {
2410 // Hook doesn't exist, create it
2411 err = os.WriteFile(hookPath, []byte(content), 0o755)
2412 if err != nil {
2413 return fmt.Errorf("failed to create hook: %w", err)
2414 }
2415 return nil
2416 }
2417 if err != nil {
2418 return fmt.Errorf("error reading existing hook: %w", err)
2419 }
2420
2421 // Hook exists, check if our content is already in it by looking for a distinctive line
2422 code := string(buf)
2423 if strings.Contains(code, distinctiveLine) {
2424 // Already contains our content, nothing to do
2425 return nil
2426 }
2427
2428 // Append our content to the existing hook
2429 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2430 if err != nil {
2431 return fmt.Errorf("failed to open hook for appending: %w", err)
2432 }
2433 defer f.Close()
2434
2435 // Ensure there's a newline at the end of the existing content if needed
2436 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2437 _, err = f.WriteString("\n")
2438 if err != nil {
2439 return fmt.Errorf("failed to add newline to hook: %w", err)
2440 }
2441 }
2442
2443 // Add a separator before our content
2444 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2445 if err != nil {
2446 return fmt.Errorf("failed to append to hook: %w", err)
2447 }
2448
2449 return nil
2450}
Sean McCullough138ec242025-06-02 22:42:06 +00002451
2452// GetPortMonitor returns the port monitor instance for accessing port events
2453func (a *Agent) GetPortMonitor() *PortMonitor {
2454 return a.portMonitor
2455}
Philip Zeyliger0113be52025-06-07 23:53:41 +00002456
2457// SkabandAddr returns the skaband address if configured
2458func (a *Agent) SkabandAddr() string {
2459 if a.config.SkabandClient != nil {
2460 return a.config.SkabandClient.Addr()
2461 }
2462 return ""
2463}