blob: 909700ea388b359c29157b82b6b5c66184f62815 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package loop
2
3import (
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07004 "cmp"
Earl Lee2e463fb2025-04-17 11:22:22 -07005 "context"
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07006 _ "embed"
Earl Lee2e463fb2025-04-17 11:22:22 -07007 "encoding/json"
8 "fmt"
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +00009 "io"
Earl Lee2e463fb2025-04-17 11:22:22 -070010 "log/slog"
11 "net/http"
12 "os"
13 "os/exec"
Pokey Rule7a113622025-05-12 10:58:45 +010014 "path/filepath"
Earl Lee2e463fb2025-04-17 11:22:22 -070015 "runtime/debug"
16 "slices"
17 "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"
Earl Lee2e463fb2025-04-17 11:22:22 -070031)
32
33const (
34 userCancelMessage = "user requested agent to stop handling responses"
35)
36
Philip Zeyligerb7c58752025-05-01 10:10:17 -070037type MessageIterator interface {
38 // Next blocks until the next message is available. It may
39 // return nil if the underlying iterator context is done.
40 Next() *AgentMessage
41 Close()
42}
43
Earl Lee2e463fb2025-04-17 11:22:22 -070044type CodingAgent interface {
45 // Init initializes an agent inside a docker container.
46 Init(AgentInit) error
47
48 // Ready returns a channel closed after Init successfully called.
49 Ready() <-chan struct{}
50
51 // URL reports the HTTP URL of this agent.
52 URL() string
53
54 // UserMessage enqueues a message to the agent and returns immediately.
55 UserMessage(ctx context.Context, msg string)
56
Philip Zeyligerb7c58752025-05-01 10:10:17 -070057 // Returns an iterator that finishes when the context is done and
58 // starts with the given message index.
59 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070060
Philip Zeyligereab12de2025-05-14 02:35:53 +000061 // Returns an iterator that notifies of state transitions until the context is done.
62 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
63
Earl Lee2e463fb2025-04-17 11:22:22 -070064 // Loop begins the agent loop returns only when ctx is cancelled.
65 Loop(ctx context.Context)
66
Sean McCulloughedc88dc2025-04-30 02:55:01 +000067 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070068
69 CancelToolUse(toolUseID string, cause error) error
70
71 // Returns a subset of the agent's message history.
72 Messages(start int, end int) []AgentMessage
73
74 // Returns the current number of messages in the history
75 MessageCount() int
76
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070077 TotalUsage() conversation.CumulativeUsage
78 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070079
Earl Lee2e463fb2025-04-17 11:22:22 -070080 WorkingDir() string
81
82 // Diff returns a unified diff of changes made since the agent was instantiated.
83 // If commit is non-nil, it shows the diff for just that specific commit.
84 Diff(commit *string) (string, error)
85
Philip Zeyliger49edc922025-05-14 09:45:45 -070086 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
87 // starts out as the commit where sketch started, but a user can move it if need
88 // be, for example in the case of a rebase. It is stored as a git tag.
89 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070090
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000091 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
92 // (Typically, this is "sketch-base")
93 SketchGitBaseRef() string
94
Earl Lee2e463fb2025-04-17 11:22:22 -070095 // Title returns the current title of the conversation.
96 Title() string
97
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000098 // BranchName returns the git branch name for the conversation.
99 BranchName() string
100
Earl Lee2e463fb2025-04-17 11:22:22 -0700101 // OS returns the operating system of the client.
102 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000103
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000104 // SessionID returns the unique session identifier.
105 SessionID() string
106
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000107 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700108 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000109
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000110 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
111 OutstandingLLMCallCount() int
112
113 // OutstandingToolCalls returns the names of outstanding tool calls.
114 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000115 OutsideOS() string
116 OutsideHostname() string
117 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000118 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000119 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
120 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700121
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700122 // IsInContainer returns true if the agent is running in a container
123 IsInContainer() bool
124 // FirstMessageIndex returns the index of the first message in the current conversation
125 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700126
127 CurrentStateName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700128}
129
130type CodingAgentMessageType string
131
132const (
133 UserMessageType CodingAgentMessageType = "user"
134 AgentMessageType CodingAgentMessageType = "agent"
135 ErrorMessageType CodingAgentMessageType = "error"
136 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
137 ToolUseMessageType CodingAgentMessageType = "tool"
138 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
139 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
140
141 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
142)
143
144type AgentMessage struct {
145 Type CodingAgentMessageType `json:"type"`
146 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
147 EndOfTurn bool `json:"end_of_turn"`
148
149 Content string `json:"content"`
150 ToolName string `json:"tool_name,omitempty"`
151 ToolInput string `json:"input,omitempty"`
152 ToolResult string `json:"tool_result,omitempty"`
153 ToolError bool `json:"tool_error,omitempty"`
154 ToolCallId string `json:"tool_call_id,omitempty"`
155
156 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
157 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
158
Sean McCulloughd9f13372025-04-21 15:08:49 -0700159 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
160 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
161
Earl Lee2e463fb2025-04-17 11:22:22 -0700162 // Commits is a list of git commits for a commit message
163 Commits []*GitCommit `json:"commits,omitempty"`
164
165 Timestamp time.Time `json:"timestamp"`
166 ConversationID string `json:"conversation_id"`
167 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700168 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700169
170 // Message timing information
171 StartTime *time.Time `json:"start_time,omitempty"`
172 EndTime *time.Time `json:"end_time,omitempty"`
173 Elapsed *time.Duration `json:"elapsed,omitempty"`
174
175 // Turn duration - the time taken for a complete agent turn
176 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
177
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000178 // HideOutput indicates that this message should not be rendered in the UI.
179 // This is useful for subconversations that generate output that shouldn't be shown to the user.
180 HideOutput bool `json:"hide_output,omitempty"`
181
Earl Lee2e463fb2025-04-17 11:22:22 -0700182 Idx int `json:"idx"`
183}
184
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000185// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700186func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700187 if convo == nil {
188 m.ConversationID = ""
189 m.ParentConversationID = nil
190 return
191 }
192 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000193 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700194 if convo.Parent != nil {
195 m.ParentConversationID = &convo.Parent.ID
196 }
197}
198
Earl Lee2e463fb2025-04-17 11:22:22 -0700199// GitCommit represents a single git commit for a commit message
200type GitCommit struct {
201 Hash string `json:"hash"` // Full commit hash
202 Subject string `json:"subject"` // Commit subject line
203 Body string `json:"body"` // Full commit message body
204 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
205}
206
207// ToolCall represents a single tool call within an agent message
208type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700209 Name string `json:"name"`
210 Input string `json:"input"`
211 ToolCallId string `json:"tool_call_id"`
212 ResultMessage *AgentMessage `json:"result_message,omitempty"`
213 Args string `json:"args,omitempty"`
214 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700215}
216
217func (a *AgentMessage) Attr() slog.Attr {
218 var attrs []any = []any{
219 slog.String("type", string(a.Type)),
220 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700221 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700222 if a.EndOfTurn {
223 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
224 }
225 if a.Content != "" {
226 attrs = append(attrs, slog.String("content", a.Content))
227 }
228 if a.ToolName != "" {
229 attrs = append(attrs, slog.String("tool_name", a.ToolName))
230 }
231 if a.ToolInput != "" {
232 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
233 }
234 if a.Elapsed != nil {
235 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
236 }
237 if a.TurnDuration != nil {
238 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
239 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700240 if len(a.ToolResult) > 0 {
241 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700242 }
243 if a.ToolError {
244 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
245 }
246 if len(a.ToolCalls) > 0 {
247 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
248 for i, tc := range a.ToolCalls {
249 toolCallAttrs = append(toolCallAttrs, slog.Group(
250 fmt.Sprintf("tool_call_%d", i),
251 slog.String("name", tc.Name),
252 slog.String("input", tc.Input),
253 ))
254 }
255 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
256 }
257 if a.ConversationID != "" {
258 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
259 }
260 if a.ParentConversationID != nil {
261 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
262 }
263 if a.Usage != nil && !a.Usage.IsZero() {
264 attrs = append(attrs, a.Usage.Attr())
265 }
266 // TODO: timestamp, convo ids, idx?
267 return slog.Group("agent_message", attrs...)
268}
269
270func errorMessage(err error) AgentMessage {
271 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
272 if os.Getenv(("DEBUG")) == "1" {
273 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
274 }
275
276 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
277}
278
279func budgetMessage(err error) AgentMessage {
280 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
281}
282
283// ConvoInterface defines the interface for conversation interactions
284type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700285 CumulativeUsage() conversation.CumulativeUsage
286 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700287 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700288 SendMessage(message llm.Message) (*llm.Response, error)
289 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700290 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000291 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700292 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700293 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700294 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700295}
296
Philip Zeyligerf2872992025-05-22 10:35:28 -0700297// AgentGitState holds the state necessary for pushing to a remote git repo
298// when HEAD changes. If gitRemoteAddr is set, then we push to sketch/
299// any time we notice we need to.
300type AgentGitState struct {
301 mu sync.Mutex // protects following
302 lastHEAD string // hash of the last HEAD that was pushed to the host
303 gitRemoteAddr string // HTTP URL of the host git repo
304 seenCommits map[string]bool // Track git commits we've already seen (by hash)
305 branchName string
306}
307
308func (ags *AgentGitState) SetBranchName(branchName string) {
309 ags.mu.Lock()
310 defer ags.mu.Unlock()
311 ags.branchName = branchName
312}
313
314func (ags *AgentGitState) BranchName() string {
315 ags.mu.Lock()
316 defer ags.mu.Unlock()
317 return ags.branchName
318}
319
Earl Lee2e463fb2025-04-17 11:22:22 -0700320type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700321 convo ConvoInterface
322 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700323 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700324 workingDir string
325 repoRoot string // workingDir may be a subdir of repoRoot
326 url string
327 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000328 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700329 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000330 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700331 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700332 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700333 title string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000334 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700335 // State machine to track agent state
336 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000337 // Outside information
338 outsideHostname string
339 outsideOS string
340 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000341 // URL of the git remote 'origin' if it exists
342 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700343
344 // Time when the current turn started (reset at the beginning of InnerLoop)
345 startOfTurn time.Time
346
347 // Inbox - for messages from the user to the agent.
348 // sent on by UserMessage
349 // . e.g. when user types into the chat textarea
350 // read from by GatherMessages
351 inbox chan string
352
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000353 // protects cancelTurn
354 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700355 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000356 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700357
358 // protects following
359 mu sync.Mutex
360
361 // Stores all messages for this agent
362 history []AgentMessage
363
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700364 // Iterators add themselves here when they're ready to be notified of new messages.
365 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700366
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000367 // Track outstanding LLM call IDs
368 outstandingLLMCalls map[string]struct{}
369
370 // Track outstanding tool calls by ID with their names
371 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700372}
373
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700374// NewIterator implements CodingAgent.
375func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
376 a.mu.Lock()
377 defer a.mu.Unlock()
378
379 return &MessageIteratorImpl{
380 agent: a,
381 ctx: ctx,
382 nextMessageIdx: nextMessageIdx,
383 ch: make(chan *AgentMessage, 100),
384 }
385}
386
387type MessageIteratorImpl struct {
388 agent *Agent
389 ctx context.Context
390 nextMessageIdx int
391 ch chan *AgentMessage
392 subscribed bool
393}
394
395func (m *MessageIteratorImpl) Close() {
396 m.agent.mu.Lock()
397 defer m.agent.mu.Unlock()
398 // Delete ourselves from the subscribers list
399 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
400 return x == m.ch
401 })
402 close(m.ch)
403}
404
405func (m *MessageIteratorImpl) Next() *AgentMessage {
406 // We avoid subscription at creation to let ourselves catch up to "current state"
407 // before subscribing.
408 if !m.subscribed {
409 m.agent.mu.Lock()
410 if m.nextMessageIdx < len(m.agent.history) {
411 msg := &m.agent.history[m.nextMessageIdx]
412 m.nextMessageIdx++
413 m.agent.mu.Unlock()
414 return msg
415 }
416 // The next message doesn't exist yet, so let's subscribe
417 m.agent.subscribers = append(m.agent.subscribers, m.ch)
418 m.subscribed = true
419 m.agent.mu.Unlock()
420 }
421
422 for {
423 select {
424 case <-m.ctx.Done():
425 m.agent.mu.Lock()
426 // Delete ourselves from the subscribers list
427 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
428 return x == m.ch
429 })
430 m.subscribed = false
431 m.agent.mu.Unlock()
432 return nil
433 case msg, ok := <-m.ch:
434 if !ok {
435 // Close may have been called
436 return nil
437 }
438 if msg.Idx == m.nextMessageIdx {
439 m.nextMessageIdx++
440 return msg
441 }
442 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
443 panic("out of order message")
444 }
445 }
446}
447
Sean McCulloughd9d45812025-04-30 16:53:41 -0700448// Assert that Agent satisfies the CodingAgent interface.
449var _ CodingAgent = &Agent{}
450
451// StateName implements CodingAgent.
452func (a *Agent) CurrentStateName() string {
453 if a.stateMachine == nil {
454 return ""
455 }
456 return a.stateMachine.currentState.String()
457}
458
Earl Lee2e463fb2025-04-17 11:22:22 -0700459func (a *Agent) URL() string { return a.url }
460
461// Title returns the current title of the conversation.
462// If no title has been set, returns an empty string.
463func (a *Agent) Title() string {
464 a.mu.Lock()
465 defer a.mu.Unlock()
466 return a.title
467}
468
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000469// BranchName returns the git branch name for the conversation.
470func (a *Agent) BranchName() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700471 return a.gitState.BranchName()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000472}
473
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000474// OutstandingLLMCallCount returns the number of outstanding LLM calls.
475func (a *Agent) OutstandingLLMCallCount() int {
476 a.mu.Lock()
477 defer a.mu.Unlock()
478 return len(a.outstandingLLMCalls)
479}
480
481// OutstandingToolCalls returns the names of outstanding tool calls.
482func (a *Agent) OutstandingToolCalls() []string {
483 a.mu.Lock()
484 defer a.mu.Unlock()
485
486 tools := make([]string, 0, len(a.outstandingToolCalls))
487 for _, toolName := range a.outstandingToolCalls {
488 tools = append(tools, toolName)
489 }
490 return tools
491}
492
Earl Lee2e463fb2025-04-17 11:22:22 -0700493// OS returns the operating system of the client.
494func (a *Agent) OS() string {
495 return a.config.ClientGOOS
496}
497
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000498func (a *Agent) SessionID() string {
499 return a.config.SessionID
500}
501
Philip Zeyliger18532b22025-04-23 21:11:46 +0000502// OutsideOS returns the operating system of the outside system.
503func (a *Agent) OutsideOS() string {
504 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000505}
506
Philip Zeyliger18532b22025-04-23 21:11:46 +0000507// OutsideHostname returns the hostname of the outside system.
508func (a *Agent) OutsideHostname() string {
509 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000510}
511
Philip Zeyliger18532b22025-04-23 21:11:46 +0000512// OutsideWorkingDir returns the working directory on the outside system.
513func (a *Agent) OutsideWorkingDir() string {
514 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000515}
516
517// GitOrigin returns the URL of the git remote 'origin' if it exists.
518func (a *Agent) GitOrigin() string {
519 return a.gitOrigin
520}
521
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000522func (a *Agent) OpenBrowser(url string) {
523 if !a.IsInContainer() {
524 browser.Open(url)
525 return
526 }
527 // We're in Docker, need to send a request to the Git server
528 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700529 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000530 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700531 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000532 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700533 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000534 return
535 }
536 defer resp.Body.Close()
537 if resp.StatusCode == http.StatusOK {
538 return
539 }
540 body, _ := io.ReadAll(resp.Body)
541 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
542}
543
Sean McCullough96b60dd2025-04-30 09:49:10 -0700544// CurrentState returns the current state of the agent's state machine.
545func (a *Agent) CurrentState() State {
546 return a.stateMachine.CurrentState()
547}
548
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700549func (a *Agent) IsInContainer() bool {
550 return a.config.InDocker
551}
552
553func (a *Agent) FirstMessageIndex() int {
554 a.mu.Lock()
555 defer a.mu.Unlock()
556 return a.firstMessageIndex
557}
558
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000559// SetTitle sets the title of the conversation.
560func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700561 a.mu.Lock()
562 defer a.mu.Unlock()
563 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000564}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700565
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000566// SetBranch sets the branch name of the conversation.
567func (a *Agent) SetBranch(branchName string) {
568 a.mu.Lock()
569 defer a.mu.Unlock()
Philip Zeyligerf2872992025-05-22 10:35:28 -0700570 a.gitState.SetBranchName(branchName)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000571 convo, ok := a.convo.(*conversation.Convo)
572 if ok {
573 convo.ExtraData["branch"] = branchName
574 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700575}
576
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000577// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700578func (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 +0000579 // Track the tool call
580 a.mu.Lock()
581 a.outstandingToolCalls[id] = toolName
582 a.mu.Unlock()
583}
584
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700585// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
586// If there's only one element in the array and it's a text type, it returns that text directly.
587// It also processes nested ToolResult arrays recursively.
588func contentToString(contents []llm.Content) string {
589 if len(contents) == 0 {
590 return ""
591 }
592
593 // If there's only one element and it's a text type, return it directly
594 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
595 return contents[0].Text
596 }
597
598 // Otherwise, concatenate all text content
599 var result strings.Builder
600 for _, content := range contents {
601 if content.Type == llm.ContentTypeText {
602 result.WriteString(content.Text)
603 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
604 // Recursively process nested tool results
605 result.WriteString(contentToString(content.ToolResult))
606 }
607 }
608
609 return result.String()
610}
611
Earl Lee2e463fb2025-04-17 11:22:22 -0700612// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700613func (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 +0000614 // Remove the tool call from outstanding calls
615 a.mu.Lock()
616 delete(a.outstandingToolCalls, toolID)
617 a.mu.Unlock()
618
Earl Lee2e463fb2025-04-17 11:22:22 -0700619 m := AgentMessage{
620 Type: ToolUseMessageType,
621 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700622 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700623 ToolError: content.ToolError,
624 ToolName: toolName,
625 ToolInput: string(toolInput),
626 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700627 StartTime: content.ToolUseStartTime,
628 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700629 }
630
631 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700632 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
633 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700634 m.Elapsed = &elapsed
635 }
636
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700637 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700638 a.pushToOutbox(ctx, m)
639}
640
641// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700642func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000643 a.mu.Lock()
644 defer a.mu.Unlock()
645 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700646 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
647}
648
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700649// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700650// that need to be displayed (as well as tool calls that we send along when
651// they're done). (It would be reasonable to also mention tool calls when they're
652// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700653func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000654 // Remove the LLM call from outstanding calls
655 a.mu.Lock()
656 delete(a.outstandingLLMCalls, id)
657 a.mu.Unlock()
658
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700659 if resp == nil {
660 // LLM API call failed
661 m := AgentMessage{
662 Type: ErrorMessageType,
663 Content: "API call failed, type 'continue' to try again",
664 }
665 m.SetConvo(convo)
666 a.pushToOutbox(ctx, m)
667 return
668 }
669
Earl Lee2e463fb2025-04-17 11:22:22 -0700670 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700671 if convo.Parent == nil { // subconvos never end the turn
672 switch resp.StopReason {
673 case llm.StopReasonToolUse:
674 // Check whether any of the tool calls are for tools that should end the turn
675 ToolSearch:
676 for _, part := range resp.Content {
677 if part.Type != llm.ContentTypeToolUse {
678 continue
679 }
Sean McCullough021557a2025-05-05 23:20:53 +0000680 // Find the tool by name
681 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700682 if tool.Name == part.ToolName {
683 endOfTurn = tool.EndsTurn
684 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000685 }
686 }
Sean McCullough021557a2025-05-05 23:20:53 +0000687 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700688 default:
689 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000690 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700691 }
692 m := AgentMessage{
693 Type: AgentMessageType,
694 Content: collectTextContent(resp),
695 EndOfTurn: endOfTurn,
696 Usage: &resp.Usage,
697 StartTime: resp.StartTime,
698 EndTime: resp.EndTime,
699 }
700
701 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700702 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700703 var toolCalls []ToolCall
704 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700705 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700706 toolCalls = append(toolCalls, ToolCall{
707 Name: part.ToolName,
708 Input: string(part.ToolInput),
709 ToolCallId: part.ID,
710 })
711 }
712 }
713 m.ToolCalls = toolCalls
714 }
715
716 // Calculate the elapsed time if both start and end times are set
717 if resp.StartTime != nil && resp.EndTime != nil {
718 elapsed := resp.EndTime.Sub(*resp.StartTime)
719 m.Elapsed = &elapsed
720 }
721
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700722 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700723 a.pushToOutbox(ctx, m)
724}
725
726// WorkingDir implements CodingAgent.
727func (a *Agent) WorkingDir() string {
728 return a.workingDir
729}
730
731// MessageCount implements CodingAgent.
732func (a *Agent) MessageCount() int {
733 a.mu.Lock()
734 defer a.mu.Unlock()
735 return len(a.history)
736}
737
738// Messages implements CodingAgent.
739func (a *Agent) Messages(start int, end int) []AgentMessage {
740 a.mu.Lock()
741 defer a.mu.Unlock()
742 return slices.Clone(a.history[start:end])
743}
744
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700745func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700746 return a.originalBudget
747}
748
749// AgentConfig contains configuration for creating a new Agent.
750type AgentConfig struct {
751 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700752 Service llm.Service
753 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700754 GitUsername string
755 GitEmail string
756 SessionID string
757 ClientGOOS string
758 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700759 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700760 UseAnthropicEdit bool
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000761 OneShot bool
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700762 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000763 // Outside information
764 OutsideHostname string
765 OutsideOS string
766 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700767
768 // Outtie's HTTP to, e.g., open a browser
769 OutsideHTTP string
770 // Outtie's Git server
771 GitRemoteAddr string
772 // Commit to checkout from Outtie
773 Commit string
Earl Lee2e463fb2025-04-17 11:22:22 -0700774}
775
776// NewAgent creates a new Agent.
777// It is not usable until Init() is called.
778func NewAgent(config AgentConfig) *Agent {
779 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -0700780 config: config,
781 ready: make(chan struct{}),
782 inbox: make(chan string, 100),
783 subscribers: make([]chan *AgentMessage, 0),
784 startedAt: time.Now(),
785 originalBudget: config.Budget,
786 gitState: AgentGitState{
787 seenCommits: make(map[string]bool),
788 gitRemoteAddr: config.GitRemoteAddr,
789 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000790 outsideHostname: config.OutsideHostname,
791 outsideOS: config.OutsideOS,
792 outsideWorkingDir: config.OutsideWorkingDir,
793 outstandingLLMCalls: make(map[string]struct{}),
794 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700795 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700796 workingDir: config.WorkingDir,
797 outsideHTTP: config.OutsideHTTP,
Earl Lee2e463fb2025-04-17 11:22:22 -0700798 }
799 return agent
800}
801
802type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700803 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -0700804
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700805 InDocker bool
806 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -0700807}
808
809func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700810 if a.convo != nil {
811 return fmt.Errorf("Agent.Init: already initialized")
812 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700813 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -0700814 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700815
Philip Zeyligerf2872992025-05-22 10:35:28 -0700816 // If a remote git addr was specified, we configure the remote
817 if a.gitState.gitRemoteAddr != "" {
818 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
819 cmd := exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", a.gitState.gitRemoteAddr)
820 cmd.Dir = a.workingDir
821 if out, err := cmd.CombinedOutput(); err != nil {
822 return fmt.Errorf("git remote add: %s: %v", out, err)
823 }
824 // sketch-host is a git repo hosted by "outtie sketch". When it notices a 'git fetch',
825 // it runs "git fetch" underneath the covers to get its latest commits. By configuring
826 // an additional remote.sketch-host.fetch, we make "origin/main" on innie sketch look like
827 // origin/main on outtie sketch, which should make it easier to rebase.
828 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.sketch-host.fetch",
829 "+refs/heads/feature/*:refs/remotes/origin/feature/*")
830 cmd.Dir = a.workingDir
831 if out, err := cmd.CombinedOutput(); err != nil {
832 return fmt.Errorf("git config --add: %s: %v", out, err)
833 }
834 }
835
836 // If a commit was specified, we fetch and reset to it.
837 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -0700838 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
839
Earl Lee2e463fb2025-04-17 11:22:22 -0700840 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700841 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -0700842 if out, err := cmd.CombinedOutput(); err != nil {
843 return fmt.Errorf("git stash: %s: %v", out, err)
844 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000845 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700846 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -0700847 if out, err := cmd.CombinedOutput(); err != nil {
848 return fmt.Errorf("git fetch: %s: %w", out, err)
849 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700850 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
851 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100852 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
853 // Remove git hooks if they exist and retry
854 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700855 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +0100856 if _, statErr := os.Stat(hookPath); statErr == nil {
857 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
858 slog.String("error", err.Error()),
859 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700860 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +0100861 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
862 }
863
864 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700865 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
866 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100867 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700868 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 +0100869 }
870 } else {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700871 return fmt.Errorf("git checkout %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +0100872 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700873 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700874 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700875
876 if ini.HostAddr != "" {
877 a.url = "http://" + ini.HostAddr
878 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700879
880 if !ini.NoGit {
881 repoRoot, err := repoRoot(ctx, a.workingDir)
882 if err != nil {
883 return fmt.Errorf("repoRoot: %w", err)
884 }
885 a.repoRoot = repoRoot
886
Earl Lee2e463fb2025-04-17 11:22:22 -0700887 if err != nil {
888 return fmt.Errorf("resolveRef: %w", err)
889 }
Philip Zeyliger49edc922025-05-14 09:45:45 -0700890
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700891 if err := setupGitHooks(a.workingDir); err != nil {
892 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
893 }
894
Philip Zeyliger49edc922025-05-14 09:45:45 -0700895 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
896 cmd.Dir = repoRoot
897 if out, err := cmd.CombinedOutput(); err != nil {
898 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
899 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700900
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +0000901 slog.Info("running codebase analysis")
902 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
903 if err != nil {
904 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000905 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +0000906 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000907
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +0000908 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -0700909 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000910 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700911 }
912 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000913
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700914 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700915 }
Philip Zeyligerf2872992025-05-22 10:35:28 -0700916 a.gitState.lastHEAD = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -0700917 a.convo = a.initConvo()
918 close(a.ready)
919 return nil
920}
921
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700922//go:embed agent_system_prompt.txt
923var agentSystemPrompt string
924
Earl Lee2e463fb2025-04-17 11:22:22 -0700925// initConvo initializes the conversation.
926// It must not be called until all agent fields are initialized,
927// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700928func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700929 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700930 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700931 convo.PromptCaching = true
932 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000933 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000934 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -0700935
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000936 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
937 bashPermissionCheck := func(command string) error {
938 // Check if branch name is set
939 a.mu.Lock()
Philip Zeyligerf2872992025-05-22 10:35:28 -0700940 branchSet := a.gitState.BranchName() != ""
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000941 a.mu.Unlock()
942
943 // If branch is set, all commands are allowed
944 if branchSet {
945 return nil
946 }
947
948 // If branch is not set, check if this is a git commit command
949 willCommit, err := bashkit.WillRunGitCommit(command)
950 if err != nil {
951 // If there's an error checking, we should allow the command to proceed
952 return nil
953 }
954
955 // If it's a git commit and branch is not set, return an error
956 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000957 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000958 }
959
960 return nil
961 }
962
963 // Create a custom bash tool with the permission check
964 bashTool := claudetool.NewBashTool(bashPermissionCheck)
965
Earl Lee2e463fb2025-04-17 11:22:22 -0700966 // Register all tools with the conversation
967 // When adding, removing, or modifying tools here, double-check that the termui tool display
968 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000969
970 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -0700971 _, supportsScreenshots := a.config.Service.(*ant.Service)
972 var bTools []*llm.Tool
973 var browserCleanup func()
974
975 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
976 // Add cleanup function to context cancel
977 go func() {
978 <-a.config.Context.Done()
979 browserCleanup()
980 }()
981 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000982
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700983 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000984 bashTool, claudetool.Keyword,
Josh Bleecher Snyder93202652025-05-08 02:05:57 +0000985 claudetool.Think, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -0700986 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000987 }
988
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000989 // One-shot mode is non-interactive, multiple choice requires human response
990 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -0700991 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -0700992 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000993
994 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -0700995 if a.config.UseAnthropicEdit {
996 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
997 } else {
998 convo.Tools = append(convo.Tools, claudetool.Patch)
999 }
1000 convo.Listener = a
1001 return convo
1002}
1003
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001004var multipleChoiceTool = &llm.Tool{
1005 Name: "multiplechoice",
1006 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.",
1007 EndsTurn: true,
1008 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001009 "type": "object",
1010 "description": "The question and a list of answers you would expect the user to choose from.",
1011 "properties": {
1012 "question": {
1013 "type": "string",
1014 "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?'"
1015 },
1016 "responseOptions": {
1017 "type": "array",
1018 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1019 "items": {
1020 "type": "object",
1021 "properties": {
1022 "caption": {
1023 "type": "string",
1024 "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'"
1025 },
1026 "responseText": {
1027 "type": "string",
1028 "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'"
1029 }
1030 },
1031 "required": ["caption", "responseText"]
1032 }
1033 }
1034 },
1035 "required": ["question", "responseOptions"]
1036}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001037 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1038 // The Run logic for "multiplechoice" tool is a no-op on the server.
1039 // The UI will present a list of options for the user to select from,
1040 // and that's it as far as "executing" the tool_use goes.
1041 // When the user *does* select one of the presented options, that
1042 // responseText gets sent as a chat message on behalf of the user.
1043 return llm.TextContent("end your turn and wait for the user to respond"), nil
1044 },
Sean McCullough485afc62025-04-28 14:28:39 -07001045}
1046
1047type MultipleChoiceOption struct {
1048 Caption string `json:"caption"`
1049 ResponseText string `json:"responseText"`
1050}
1051
1052type MultipleChoiceParams struct {
1053 Question string `json:"question"`
1054 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1055}
1056
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001057// branchExists reports whether branchName exists, either locally or in well-known remotes.
1058func branchExists(dir, branchName string) bool {
1059 refs := []string{
1060 "refs/heads/",
1061 "refs/remotes/origin/",
1062 "refs/remotes/sketch-host/",
1063 }
1064 for _, ref := range refs {
1065 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1066 cmd.Dir = dir
1067 if cmd.Run() == nil { // exit code 0 means branch exists
1068 return true
1069 }
1070 }
1071 return false
1072}
1073
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001074func (a *Agent) titleTool() *llm.Tool {
1075 description := `Sets the conversation title.`
1076 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001077 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001078 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001079 InputSchema: json.RawMessage(`{
1080 "type": "object",
1081 "properties": {
1082 "title": {
1083 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001084 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001085 }
1086 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001087 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001088}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001089 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001090 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001091 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001092 }
1093 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001094 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001095 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001096
1097 // We don't allow changing the title once set to be consistent with the previous behavior
1098 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001099 t := a.Title()
1100 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001101 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001102 }
1103
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001104 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001105 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001106 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001107
1108 a.SetTitle(params.Title)
1109 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001110 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001111 },
1112 }
1113 return titleTool
1114}
1115
1116func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001117 description := `Creates a git branch for tracking work and 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 +00001118 preCommit := &llm.Tool{
1119 Name: "precommit",
1120 Description: description,
1121 InputSchema: json.RawMessage(`{
1122 "type": "object",
1123 "properties": {
1124 "branch_name": {
1125 "type": "string",
1126 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1127 }
1128 },
1129 "required": ["branch_name"]
1130}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001131 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001132 var params struct {
1133 BranchName string `json:"branch_name"`
1134 }
1135 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001136 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001137 }
1138
1139 b := a.BranchName()
1140 if b != "" {
Josh Bleecher Snyder44d1f1a2025-05-12 19:18:32 -07001141 return nil, fmt.Errorf("branch already set to %s; do not create a new branch", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001142 }
1143
1144 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001145 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001146 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001147 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001148 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001149 }
1150 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001151 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001152 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001153 }
1154
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001155 a.SetBranch(branchName)
Josh Bleecher Snyderf7bebdd2025-05-14 15:22:24 -07001156 response := fmt.Sprintf("switched to branch sketch/%q - DO NOT change branches unless explicitly requested", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001157
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001158 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1159 if err != nil {
1160 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1161 }
1162 if len(styleHint) > 0 {
1163 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001164 }
1165
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001166 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001167 },
1168 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001169 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001170}
1171
1172func (a *Agent) Ready() <-chan struct{} {
1173 return a.ready
1174}
1175
1176func (a *Agent) UserMessage(ctx context.Context, msg string) {
1177 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1178 a.inbox <- msg
1179}
1180
Earl Lee2e463fb2025-04-17 11:22:22 -07001181func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1182 return a.convo.CancelToolUse(toolUseID, cause)
1183}
1184
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001185func (a *Agent) CancelTurn(cause error) {
1186 a.cancelTurnMu.Lock()
1187 defer a.cancelTurnMu.Unlock()
1188 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001189 // Force state transition to cancelled state
1190 ctx := a.config.Context
1191 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001192 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001193 }
1194}
1195
1196func (a *Agent) Loop(ctxOuter context.Context) {
1197 for {
1198 select {
1199 case <-ctxOuter.Done():
1200 return
1201 default:
1202 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001203 a.cancelTurnMu.Lock()
1204 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001205 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001206 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001207 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001208 a.cancelTurn = cancel
1209 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001210 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1211 if err != nil {
1212 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1213 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001214 cancel(nil)
1215 }
1216 }
1217}
1218
1219func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1220 if m.Timestamp.IsZero() {
1221 m.Timestamp = time.Now()
1222 }
1223
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001224 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1225 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1226 m.Content = m.ToolResult
1227 }
1228
Earl Lee2e463fb2025-04-17 11:22:22 -07001229 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1230 if m.EndOfTurn && m.Type == AgentMessageType {
1231 turnDuration := time.Since(a.startOfTurn)
1232 m.TurnDuration = &turnDuration
1233 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1234 }
1235
Earl Lee2e463fb2025-04-17 11:22:22 -07001236 a.mu.Lock()
1237 defer a.mu.Unlock()
1238 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001239 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001240 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001241
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001242 // Notify all subscribers
1243 for _, ch := range a.subscribers {
1244 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001245 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001246}
1247
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001248func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1249 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001250 if block {
1251 select {
1252 case <-ctx.Done():
1253 return m, ctx.Err()
1254 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001255 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001256 }
1257 }
1258 for {
1259 select {
1260 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001261 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001262 default:
1263 return m, nil
1264 }
1265 }
1266}
1267
Sean McCullough885a16a2025-04-30 02:49:25 +00001268// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001269func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001270 // Reset the start of turn time
1271 a.startOfTurn = time.Now()
1272
Sean McCullough96b60dd2025-04-30 09:49:10 -07001273 // Transition to waiting for user input state
1274 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1275
Sean McCullough885a16a2025-04-30 02:49:25 +00001276 // Process initial user message
1277 initialResp, err := a.processUserMessage(ctx)
1278 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001279 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001280 return err
1281 }
1282
1283 // Handle edge case where both initialResp and err are nil
1284 if initialResp == nil {
1285 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001286 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1287
Sean McCullough9f4b8082025-04-30 17:34:07 +00001288 a.pushToOutbox(ctx, errorMessage(err))
1289 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001290 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001291
Earl Lee2e463fb2025-04-17 11:22:22 -07001292 // We do this as we go, but let's also do it at the end of the turn
1293 defer func() {
1294 if _, err := a.handleGitCommits(ctx); err != nil {
1295 // Just log the error, don't stop execution
1296 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1297 }
1298 }()
1299
Sean McCullougha1e0e492025-05-01 10:51:08 -07001300 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001301 resp := initialResp
1302 for {
1303 // Check if we are over budget
1304 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001305 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001306 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001307 }
1308
1309 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001310 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001311 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001312 break
1313 }
1314
Sean McCullough96b60dd2025-04-30 09:49:10 -07001315 // Transition to tool use requested state
1316 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1317
Sean McCullough885a16a2025-04-30 02:49:25 +00001318 // Handle tool execution
1319 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1320 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001321 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001322 }
1323
Sean McCullougha1e0e492025-05-01 10:51:08 -07001324 if toolResp == nil {
1325 return fmt.Errorf("cannot continue conversation with a nil tool response")
1326 }
1327
Sean McCullough885a16a2025-04-30 02:49:25 +00001328 // Set the response for the next iteration
1329 resp = toolResp
1330 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001331
1332 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001333}
1334
1335// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001336func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001337 // Wait for at least one message from the user
1338 msgs, err := a.GatherMessages(ctx, true)
1339 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001340 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001341 return nil, err
1342 }
1343
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001344 userMessage := llm.Message{
1345 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001346 Content: msgs,
1347 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001348
Sean McCullough96b60dd2025-04-30 09:49:10 -07001349 // Transition to sending to LLM state
1350 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1351
Sean McCullough885a16a2025-04-30 02:49:25 +00001352 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001353 resp, err := a.convo.SendMessage(userMessage)
1354 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001355 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001356 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001357 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001358 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001359
Sean McCullough96b60dd2025-04-30 09:49:10 -07001360 // Transition to processing LLM response state
1361 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1362
Sean McCullough885a16a2025-04-30 02:49:25 +00001363 return resp, nil
1364}
1365
1366// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001367func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1368 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001369 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001370 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001371
Sean McCullough96b60dd2025-04-30 09:49:10 -07001372 // Transition to checking for cancellation state
1373 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1374
Sean McCullough885a16a2025-04-30 02:49:25 +00001375 // Check if the operation was cancelled by the user
1376 select {
1377 case <-ctx.Done():
1378 // Don't actually run any of the tools, but rather build a response
1379 // for each tool_use message letting the LLM know that user canceled it.
1380 var err error
1381 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001382 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001383 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001384 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001385 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001386 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001387 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001388 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001389 // Transition to running tool state
1390 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1391
Sean McCullough885a16a2025-04-30 02:49:25 +00001392 // Add working directory to context for tool execution
1393 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1394
1395 // Execute the tools
1396 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001397 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001398 if ctx.Err() != nil { // e.g. the user canceled the operation
1399 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001400 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001401 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001402 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001403 a.pushToOutbox(ctx, errorMessage(err))
1404 }
1405 }
1406
1407 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001408 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001409 autoqualityMessages := a.processGitChanges(ctx)
1410
1411 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001412 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001413 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001414 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001415 return false, nil
1416 }
1417
1418 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001419 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1420 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001421}
1422
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001423// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001424func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001425 // Check for git commits
1426 _, err := a.handleGitCommits(ctx)
1427 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001428 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001429 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001430 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001431 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001432}
1433
1434// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1435// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001436func (a *Agent) processGitChanges(ctx context.Context) []string {
1437 // Check for git commits after tool execution
1438 newCommits, err := a.handleGitCommits(ctx)
1439 if err != nil {
1440 // Just log the error, don't stop execution
1441 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1442 return nil
1443 }
1444
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001445 // Run mechanical checks if there was exactly one new commit.
1446 if len(newCommits) != 1 {
1447 return nil
1448 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001449 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001450 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1451 msg := a.codereview.RunMechanicalChecks(ctx)
1452 if msg != "" {
1453 a.pushToOutbox(ctx, AgentMessage{
1454 Type: AutoMessageType,
1455 Content: msg,
1456 Timestamp: time.Now(),
1457 })
1458 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001459 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001460
1461 return autoqualityMessages
1462}
1463
1464// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001465func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001466 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001467 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001468 msgs, err := a.GatherMessages(ctx, false)
1469 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001470 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001471 return false, nil
1472 }
1473
1474 // Inject any auto-generated messages from quality checks
1475 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001476 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001477 }
1478
1479 // Handle cancellation by appending a message about it
1480 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001481 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001482 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001483 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001484 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1485 } else if err := a.convo.OverBudget(); err != nil {
1486 // Handle budget issues by appending a message about it
1487 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 -07001488 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001489 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1490 }
1491
1492 // Combine tool results with user messages
1493 results = append(results, msgs...)
1494
1495 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001496 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001497 resp, err := a.convo.SendMessage(llm.Message{
1498 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001499 Content: results,
1500 })
1501 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001502 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001503 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1504 return true, nil // Return true to continue the conversation, but with no response
1505 }
1506
Sean McCullough96b60dd2025-04-30 09:49:10 -07001507 // Transition back to processing LLM response
1508 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1509
Sean McCullough885a16a2025-04-30 02:49:25 +00001510 if cancelled {
1511 return false, nil
1512 }
1513
1514 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001515}
1516
1517func (a *Agent) overBudget(ctx context.Context) error {
1518 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001519 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001520 m := budgetMessage(err)
1521 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001522 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001523 a.convo.ResetBudget(a.originalBudget)
1524 return err
1525 }
1526 return nil
1527}
1528
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001529func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001530 // Collect all text content
1531 var allText strings.Builder
1532 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001533 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001534 if allText.Len() > 0 {
1535 allText.WriteString("\n\n")
1536 }
1537 allText.WriteString(content.Text)
1538 }
1539 }
1540 return allText.String()
1541}
1542
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001543func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001544 a.mu.Lock()
1545 defer a.mu.Unlock()
1546 return a.convo.CumulativeUsage()
1547}
1548
Earl Lee2e463fb2025-04-17 11:22:22 -07001549// Diff returns a unified diff of changes made since the agent was instantiated.
1550func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001551 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001552 return "", fmt.Errorf("no initial commit reference available")
1553 }
1554
1555 // Find the repository root
1556 ctx := context.Background()
1557
1558 // If a specific commit hash is provided, show just that commit's changes
1559 if commit != nil && *commit != "" {
1560 // Validate that the commit looks like a valid git SHA
1561 if !isValidGitSHA(*commit) {
1562 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1563 }
1564
1565 // Get the diff for just this commit
1566 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1567 cmd.Dir = a.repoRoot
1568 output, err := cmd.CombinedOutput()
1569 if err != nil {
1570 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1571 }
1572 return string(output), nil
1573 }
1574
1575 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001576 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001577 cmd.Dir = a.repoRoot
1578 output, err := cmd.CombinedOutput()
1579 if err != nil {
1580 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1581 }
1582
1583 return string(output), nil
1584}
1585
Philip Zeyliger49edc922025-05-14 09:45:45 -07001586// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1587// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1588func (a *Agent) SketchGitBaseRef() string {
1589 if a.IsInContainer() {
1590 return "sketch-base"
1591 } else {
1592 return "sketch-base-" + a.SessionID()
1593 }
1594}
1595
1596// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1597func (a *Agent) SketchGitBase() string {
1598 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1599 cmd.Dir = a.repoRoot
1600 output, err := cmd.CombinedOutput()
1601 if err != nil {
1602 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1603 return "HEAD"
1604 }
1605 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001606}
1607
Pokey Rule7a113622025-05-12 10:58:45 +01001608// removeGitHooks removes the Git hooks directory from the repository
1609func removeGitHooks(_ context.Context, repoPath string) error {
1610 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1611
1612 // Check if hooks directory exists
1613 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1614 // Directory doesn't exist, nothing to do
1615 return nil
1616 }
1617
1618 // Remove the hooks directory
1619 err := os.RemoveAll(hooksDir)
1620 if err != nil {
1621 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1622 }
1623
1624 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001625 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001626 if err != nil {
1627 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1628 }
1629
1630 return nil
1631}
1632
Philip Zeyligerf2872992025-05-22 10:35:28 -07001633func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1634 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef())
1635 for _, msg := range msgs {
1636 a.pushToOutbox(ctx, msg)
1637 }
1638 return commits, error
1639}
1640
Earl Lee2e463fb2025-04-17 11:22:22 -07001641// handleGitCommits() highlights new commits to the user. When running
1642// under docker, new HEADs are pushed to a branch according to the title.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001643func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string) ([]AgentMessage, []*GitCommit, error) {
1644 ags.mu.Lock()
1645 defer ags.mu.Unlock()
1646
1647 msgs := []AgentMessage{}
1648 if repoRoot == "" {
1649 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001650 }
1651
Philip Zeyligerf2872992025-05-22 10:35:28 -07001652 head, err := resolveRef(ctx, repoRoot, "HEAD")
Earl Lee2e463fb2025-04-17 11:22:22 -07001653 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001654 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001655 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001656 if head == ags.lastHEAD {
1657 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001658 }
1659 defer func() {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001660 ags.lastHEAD = head
Earl Lee2e463fb2025-04-17 11:22:22 -07001661 }()
1662
1663 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1664 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1665 // to the last 100 commits.
1666 var commits []*GitCommit
1667
1668 // Get commits since the initial commit
1669 // Format: <hash>\0<subject>\0<body>\0
1670 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1671 // Limit to 100 commits to avoid overwhelming the user
Philip Zeyligerf2872992025-05-22 10:35:28 -07001672 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, head)
1673 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001674 output, err := cmd.Output()
1675 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001676 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001677 }
1678
1679 // Parse git log output and filter out already seen commits
1680 parsedCommits := parseGitLog(string(output))
1681
1682 var headCommit *GitCommit
1683
1684 // Filter out commits we've already seen
1685 for _, commit := range parsedCommits {
1686 if commit.Hash == head {
1687 headCommit = &commit
1688 }
1689
1690 // Skip if we've seen this commit before. If our head has changed, always include that.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001691 if ags.seenCommits[commit.Hash] && commit.Hash != head {
Earl Lee2e463fb2025-04-17 11:22:22 -07001692 continue
1693 }
1694
1695 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001696 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001697
1698 // Add to our list of new commits
1699 commits = append(commits, &commit)
1700 }
1701
Philip Zeyligerf2872992025-05-22 10:35:28 -07001702 if ags.gitRemoteAddr != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001703 if headCommit == nil {
1704 // I think this can only happen if we have a bug or if there's a race.
1705 headCommit = &GitCommit{}
1706 headCommit.Hash = head
1707 headCommit.Subject = "unknown"
1708 commits = append(commits, headCommit)
1709 }
1710
Philip Zeyligerf2872992025-05-22 10:35:28 -07001711 originalBranch := cmp.Or(ags.branchName, "sketch/"+sessionID)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001712 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001713
1714 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1715 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1716 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001717
1718 // Try up to 10 times with different branch names if the branch is checked out on the remote
1719 var out []byte
1720 var err error
1721 for retries := range 10 {
1722 if retries > 0 {
1723 // Add a numeric suffix to the branch name
1724 branch = fmt.Sprintf("%s%d", originalBranch, retries)
1725 }
1726
Philip Zeyligerf2872992025-05-22 10:35:28 -07001727 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1728 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001729 out, err = cmd.CombinedOutput()
1730
1731 if err == nil {
1732 // Success! Break out of the retry loop
1733 break
1734 }
1735
1736 // Check if this is the "refusing to update checked out branch" error
1737 if !strings.Contains(string(out), "refusing to update checked out branch") {
1738 // This is a different error, so don't retry
1739 break
1740 }
1741
1742 // If we're on the last retry, we'll report the error
1743 if retries == 9 {
1744 break
1745 }
1746 }
1747
1748 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001749 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001750 } else {
1751 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001752 // Update the agent's branch name if we ended up using a different one
1753 if branch != originalBranch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001754 ags.branchName = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001755 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001756 }
1757 }
1758
1759 // If we found new commits, create a message
1760 if len(commits) > 0 {
1761 msg := AgentMessage{
1762 Type: CommitMessageType,
1763 Timestamp: time.Now(),
1764 Commits: commits,
1765 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001766 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001767 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001768 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001769}
1770
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001771func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001772 return strings.Map(func(r rune) rune {
1773 // lowercase
1774 if r >= 'A' && r <= 'Z' {
1775 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001776 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001777 // replace spaces with dashes
1778 if r == ' ' {
1779 return '-'
1780 }
1781 // allow alphanumerics and dashes
1782 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1783 return r
1784 }
1785 return -1
1786 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001787}
1788
1789// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1790// and returns an array of GitCommit structs.
1791func parseGitLog(output string) []GitCommit {
1792 var commits []GitCommit
1793
1794 // No output means no commits
1795 if len(output) == 0 {
1796 return commits
1797 }
1798
1799 // Split by NULL byte
1800 parts := strings.Split(output, "\x00")
1801
1802 // Process in triplets (hash, subject, body)
1803 for i := 0; i < len(parts); i++ {
1804 // Skip empty parts
1805 if parts[i] == "" {
1806 continue
1807 }
1808
1809 // This should be a hash
1810 hash := strings.TrimSpace(parts[i])
1811
1812 // Make sure we have at least a subject part available
1813 if i+1 >= len(parts) {
1814 break // No more parts available
1815 }
1816
1817 // Get the subject
1818 subject := strings.TrimSpace(parts[i+1])
1819
1820 // Get the body if available
1821 body := ""
1822 if i+2 < len(parts) {
1823 body = strings.TrimSpace(parts[i+2])
1824 }
1825
1826 // Skip to the next triplet
1827 i += 2
1828
1829 commits = append(commits, GitCommit{
1830 Hash: hash,
1831 Subject: subject,
1832 Body: body,
1833 })
1834 }
1835
1836 return commits
1837}
1838
1839func repoRoot(ctx context.Context, dir string) (string, error) {
1840 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1841 stderr := new(strings.Builder)
1842 cmd.Stderr = stderr
1843 cmd.Dir = dir
1844 out, err := cmd.Output()
1845 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001846 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07001847 }
1848 return strings.TrimSpace(string(out)), nil
1849}
1850
1851func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1852 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1853 stderr := new(strings.Builder)
1854 cmd.Stderr = stderr
1855 cmd.Dir = dir
1856 out, err := cmd.Output()
1857 if err != nil {
1858 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1859 }
1860 // TODO: validate that out is valid hex
1861 return strings.TrimSpace(string(out)), nil
1862}
1863
1864// isValidGitSHA validates if a string looks like a valid git SHA hash.
1865// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1866func isValidGitSHA(sha string) bool {
1867 // Git SHA must be a hexadecimal string with at least 4 characters
1868 if len(sha) < 4 || len(sha) > 40 {
1869 return false
1870 }
1871
1872 // Check if the string only contains hexadecimal characters
1873 for _, char := range sha {
1874 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1875 return false
1876 }
1877 }
1878
1879 return true
1880}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001881
1882// getGitOrigin returns the URL of the git remote 'origin' if it exists
1883func getGitOrigin(ctx context.Context, dir string) string {
1884 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1885 cmd.Dir = dir
1886 stderr := new(strings.Builder)
1887 cmd.Stderr = stderr
1888 out, err := cmd.Output()
1889 if err != nil {
1890 return ""
1891 }
1892 return strings.TrimSpace(string(out))
1893}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001894
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001895// systemPromptData contains the data used to render the system prompt template
1896type systemPromptData struct {
1897 EditPrompt string
1898 ClientGOOS string
1899 ClientGOARCH string
1900 WorkingDir string
1901 RepoRoot string
1902 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001903 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001904}
1905
1906// renderSystemPrompt renders the system prompt template.
1907func (a *Agent) renderSystemPrompt() string {
1908 // Determine the appropriate edit prompt based on config
1909 var editPrompt string
1910 if a.config.UseAnthropicEdit {
1911 editPrompt = "Then use the str_replace_editor tool to make those edits. For short complete file replacements, you may use the bash tool with cat and heredoc stdin."
1912 } else {
1913 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1914 }
1915
1916 data := systemPromptData{
1917 EditPrompt: editPrompt,
1918 ClientGOOS: a.config.ClientGOOS,
1919 ClientGOARCH: a.config.ClientGOARCH,
1920 WorkingDir: a.workingDir,
1921 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07001922 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001923 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001924 }
1925
1926 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1927 if err != nil {
1928 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1929 }
1930 buf := new(strings.Builder)
1931 err = tmpl.Execute(buf, data)
1932 if err != nil {
1933 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1934 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001935 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001936 return buf.String()
1937}
Philip Zeyligereab12de2025-05-14 02:35:53 +00001938
1939// StateTransitionIterator provides an iterator over state transitions.
1940type StateTransitionIterator interface {
1941 // Next blocks until a new state transition is available or context is done.
1942 // Returns nil if the context is cancelled.
1943 Next() *StateTransition
1944 // Close removes the listener and cleans up resources.
1945 Close()
1946}
1947
1948// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
1949type StateTransitionIteratorImpl struct {
1950 agent *Agent
1951 ctx context.Context
1952 ch chan StateTransition
1953 unsubscribe func()
1954}
1955
1956// Next blocks until a new state transition is available or the context is cancelled.
1957func (s *StateTransitionIteratorImpl) Next() *StateTransition {
1958 select {
1959 case <-s.ctx.Done():
1960 return nil
1961 case transition, ok := <-s.ch:
1962 if !ok {
1963 return nil
1964 }
1965 transitionCopy := transition
1966 return &transitionCopy
1967 }
1968}
1969
1970// Close removes the listener and cleans up resources.
1971func (s *StateTransitionIteratorImpl) Close() {
1972 if s.unsubscribe != nil {
1973 s.unsubscribe()
1974 s.unsubscribe = nil
1975 }
1976}
1977
1978// NewStateTransitionIterator returns an iterator that receives state transitions.
1979func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
1980 a.mu.Lock()
1981 defer a.mu.Unlock()
1982
1983 // Create channel to receive state transitions
1984 ch := make(chan StateTransition, 10)
1985
1986 // Add a listener to the state machine
1987 unsubscribe := a.stateMachine.AddTransitionListener(ch)
1988
1989 return &StateTransitionIteratorImpl{
1990 agent: a,
1991 ctx: ctx,
1992 ch: ch,
1993 unsubscribe: unsubscribe,
1994 }
1995}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00001996
1997// setupGitHooks creates or updates git hooks in the specified working directory.
1998func setupGitHooks(workingDir string) error {
1999 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2000
2001 _, err := os.Stat(hooksDir)
2002 if os.IsNotExist(err) {
2003 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2004 }
2005 if err != nil {
2006 return fmt.Errorf("error checking git hooks directory: %w", err)
2007 }
2008
2009 // Define the post-commit hook content
2010 postCommitHook := `#!/bin/bash
2011echo "<post_commit_hook>"
2012echo "Please review this commit message and fix it if it is incorrect."
2013echo "This hook only echos the commit message; it does not modify it."
2014echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2015echo "<last_commit_message>"
2016git log -1 --pretty=%B
2017echo "</last_commit_message>"
2018echo "</post_commit_hook>"
2019`
2020
2021 // Define the prepare-commit-msg hook content
2022 prepareCommitMsgHook := `#!/bin/bash
2023# Add Co-Authored-By and Change-ID trailers to commit messages
2024# Check if these trailers already exist before adding them
2025
2026commit_file="$1"
2027COMMIT_SOURCE="$2"
2028
2029# Skip for merges, squashes, or when using a commit template
2030if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2031 [ "$COMMIT_SOURCE" = "squash" ]; then
2032 exit 0
2033fi
2034
2035commit_msg=$(cat "$commit_file")
2036
2037needs_co_author=true
2038needs_change_id=true
2039
2040# Check if commit message already has Co-Authored-By trailer
2041if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2042 needs_co_author=false
2043fi
2044
2045# Check if commit message already has Change-ID trailer
2046if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2047 needs_change_id=false
2048fi
2049
2050# Only modify if at least one trailer needs to be added
2051if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
2052 # Ensure there's a blank line before trailers
2053 if [ -s "$commit_file" ] && [ "$(tail -1 "$commit_file" | tr -d '\n')" != "" ]; then
2054 echo "" >> "$commit_file"
2055 fi
2056
2057 # Add trailers if needed
2058 if [ "$needs_co_author" = true ]; then
2059 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2060 fi
2061
2062 if [ "$needs_change_id" = true ]; then
2063 change_id=$(openssl rand -hex 8)
2064 echo "Change-ID: s${change_id}k" >> "$commit_file"
2065 fi
2066fi
2067`
2068
2069 // Update or create the post-commit hook
2070 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2071 if err != nil {
2072 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2073 }
2074
2075 // Update or create the prepare-commit-msg hook
2076 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2077 if err != nil {
2078 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2079 }
2080
2081 return nil
2082}
2083
2084// updateOrCreateHook creates a new hook file or updates an existing one
2085// by appending the new content if it doesn't already contain it.
2086func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2087 // Check if the hook already exists
2088 buf, err := os.ReadFile(hookPath)
2089 if os.IsNotExist(err) {
2090 // Hook doesn't exist, create it
2091 err = os.WriteFile(hookPath, []byte(content), 0o755)
2092 if err != nil {
2093 return fmt.Errorf("failed to create hook: %w", err)
2094 }
2095 return nil
2096 }
2097 if err != nil {
2098 return fmt.Errorf("error reading existing hook: %w", err)
2099 }
2100
2101 // Hook exists, check if our content is already in it by looking for a distinctive line
2102 code := string(buf)
2103 if strings.Contains(code, distinctiveLine) {
2104 // Already contains our content, nothing to do
2105 return nil
2106 }
2107
2108 // Append our content to the existing hook
2109 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2110 if err != nil {
2111 return fmt.Errorf("failed to open hook for appending: %w", err)
2112 }
2113 defer f.Close()
2114
2115 // Ensure there's a newline at the end of the existing content if needed
2116 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2117 _, err = f.WriteString("\n")
2118 if err != nil {
2119 return fmt.Errorf("failed to add newline to hook: %w", err)
2120 }
2121 }
2122
2123 // Add a separator before our content
2124 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2125 if err != nil {
2126 return fmt.Errorf("failed to append to hook: %w", err)
2127 }
2128
2129 return nil
2130}