blob: ce7d8e384c2683477b46add6dd6844a39ecaa359 [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
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000081 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070082
83 // Diff returns a unified diff of changes made since the agent was instantiated.
84 // If commit is non-nil, it shows the diff for just that specific commit.
85 Diff(commit *string) (string, error)
86
Philip Zeyliger49edc922025-05-14 09:45:45 -070087 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
88 // starts out as the commit where sketch started, but a user can move it if need
89 // be, for example in the case of a rebase. It is stored as a git tag.
90 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070091
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000092 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
93 // (Typically, this is "sketch-base")
94 SketchGitBaseRef() string
95
Earl Lee2e463fb2025-04-17 11:22:22 -070096 // Title returns the current title of the conversation.
97 Title() string
98
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000099 // BranchName returns the git branch name for the conversation.
100 BranchName() string
101
Earl Lee2e463fb2025-04-17 11:22:22 -0700102 // OS returns the operating system of the client.
103 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000104
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000105 // SessionID returns the unique session identifier.
106 SessionID() string
107
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000108 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700109 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000110
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000111 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
112 OutstandingLLMCallCount() int
113
114 // OutstandingToolCalls returns the names of outstanding tool calls.
115 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000116 OutsideOS() string
117 OutsideHostname() string
118 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000119 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000120 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
121 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700122
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700123 // IsInContainer returns true if the agent is running in a container
124 IsInContainer() bool
125 // FirstMessageIndex returns the index of the first message in the current conversation
126 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700127
128 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700129 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
130 CurrentTodoContent() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700131}
132
133type CodingAgentMessageType string
134
135const (
136 UserMessageType CodingAgentMessageType = "user"
137 AgentMessageType CodingAgentMessageType = "agent"
138 ErrorMessageType CodingAgentMessageType = "error"
139 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
140 ToolUseMessageType CodingAgentMessageType = "tool"
141 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
142 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
143
144 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
145)
146
147type AgentMessage struct {
148 Type CodingAgentMessageType `json:"type"`
149 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
150 EndOfTurn bool `json:"end_of_turn"`
151
152 Content string `json:"content"`
153 ToolName string `json:"tool_name,omitempty"`
154 ToolInput string `json:"input,omitempty"`
155 ToolResult string `json:"tool_result,omitempty"`
156 ToolError bool `json:"tool_error,omitempty"`
157 ToolCallId string `json:"tool_call_id,omitempty"`
158
159 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
160 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
161
Sean McCulloughd9f13372025-04-21 15:08:49 -0700162 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
163 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
164
Earl Lee2e463fb2025-04-17 11:22:22 -0700165 // Commits is a list of git commits for a commit message
166 Commits []*GitCommit `json:"commits,omitempty"`
167
168 Timestamp time.Time `json:"timestamp"`
169 ConversationID string `json:"conversation_id"`
170 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700171 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700172
173 // Message timing information
174 StartTime *time.Time `json:"start_time,omitempty"`
175 EndTime *time.Time `json:"end_time,omitempty"`
176 Elapsed *time.Duration `json:"elapsed,omitempty"`
177
178 // Turn duration - the time taken for a complete agent turn
179 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
180
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000181 // HideOutput indicates that this message should not be rendered in the UI.
182 // This is useful for subconversations that generate output that shouldn't be shown to the user.
183 HideOutput bool `json:"hide_output,omitempty"`
184
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700185 // TodoContent contains the agent's todo file content when it has changed
186 TodoContent *string `json:"todo_content,omitempty"`
187
Earl Lee2e463fb2025-04-17 11:22:22 -0700188 Idx int `json:"idx"`
189}
190
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000191// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700192func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700193 if convo == nil {
194 m.ConversationID = ""
195 m.ParentConversationID = nil
196 return
197 }
198 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000199 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700200 if convo.Parent != nil {
201 m.ParentConversationID = &convo.Parent.ID
202 }
203}
204
Earl Lee2e463fb2025-04-17 11:22:22 -0700205// GitCommit represents a single git commit for a commit message
206type GitCommit struct {
207 Hash string `json:"hash"` // Full commit hash
208 Subject string `json:"subject"` // Commit subject line
209 Body string `json:"body"` // Full commit message body
210 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
211}
212
213// ToolCall represents a single tool call within an agent message
214type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700215 Name string `json:"name"`
216 Input string `json:"input"`
217 ToolCallId string `json:"tool_call_id"`
218 ResultMessage *AgentMessage `json:"result_message,omitempty"`
219 Args string `json:"args,omitempty"`
220 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700221}
222
223func (a *AgentMessage) Attr() slog.Attr {
224 var attrs []any = []any{
225 slog.String("type", string(a.Type)),
226 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700227 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700228 if a.EndOfTurn {
229 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
230 }
231 if a.Content != "" {
232 attrs = append(attrs, slog.String("content", a.Content))
233 }
234 if a.ToolName != "" {
235 attrs = append(attrs, slog.String("tool_name", a.ToolName))
236 }
237 if a.ToolInput != "" {
238 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
239 }
240 if a.Elapsed != nil {
241 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
242 }
243 if a.TurnDuration != nil {
244 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
245 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700246 if len(a.ToolResult) > 0 {
247 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700248 }
249 if a.ToolError {
250 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
251 }
252 if len(a.ToolCalls) > 0 {
253 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
254 for i, tc := range a.ToolCalls {
255 toolCallAttrs = append(toolCallAttrs, slog.Group(
256 fmt.Sprintf("tool_call_%d", i),
257 slog.String("name", tc.Name),
258 slog.String("input", tc.Input),
259 ))
260 }
261 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
262 }
263 if a.ConversationID != "" {
264 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
265 }
266 if a.ParentConversationID != nil {
267 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
268 }
269 if a.Usage != nil && !a.Usage.IsZero() {
270 attrs = append(attrs, a.Usage.Attr())
271 }
272 // TODO: timestamp, convo ids, idx?
273 return slog.Group("agent_message", attrs...)
274}
275
276func errorMessage(err error) AgentMessage {
277 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
278 if os.Getenv(("DEBUG")) == "1" {
279 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
280 }
281
282 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
283}
284
285func budgetMessage(err error) AgentMessage {
286 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
287}
288
289// ConvoInterface defines the interface for conversation interactions
290type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700291 CumulativeUsage() conversation.CumulativeUsage
292 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700293 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700294 SendMessage(message llm.Message) (*llm.Response, error)
295 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700296 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000297 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700298 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700299 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700300 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700301}
302
Philip Zeyligerf2872992025-05-22 10:35:28 -0700303// AgentGitState holds the state necessary for pushing to a remote git repo
304// when HEAD changes. If gitRemoteAddr is set, then we push to sketch/
305// any time we notice we need to.
306type AgentGitState struct {
307 mu sync.Mutex // protects following
308 lastHEAD string // hash of the last HEAD that was pushed to the host
309 gitRemoteAddr string // HTTP URL of the host git repo
310 seenCommits map[string]bool // Track git commits we've already seen (by hash)
311 branchName string
312}
313
314func (ags *AgentGitState) SetBranchName(branchName string) {
315 ags.mu.Lock()
316 defer ags.mu.Unlock()
317 ags.branchName = branchName
318}
319
320func (ags *AgentGitState) BranchName() string {
321 ags.mu.Lock()
322 defer ags.mu.Unlock()
323 return ags.branchName
324}
325
Earl Lee2e463fb2025-04-17 11:22:22 -0700326type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700327 convo ConvoInterface
328 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700329 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700330 workingDir string
331 repoRoot string // workingDir may be a subdir of repoRoot
332 url string
333 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000334 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700335 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000336 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700337 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700338 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700339 title string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000340 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700341 // State machine to track agent state
342 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000343 // Outside information
344 outsideHostname string
345 outsideOS string
346 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000347 // URL of the git remote 'origin' if it exists
348 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700349
350 // Time when the current turn started (reset at the beginning of InnerLoop)
351 startOfTurn time.Time
352
353 // Inbox - for messages from the user to the agent.
354 // sent on by UserMessage
355 // . e.g. when user types into the chat textarea
356 // read from by GatherMessages
357 inbox chan string
358
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000359 // protects cancelTurn
360 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700361 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000362 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700363
364 // protects following
365 mu sync.Mutex
366
367 // Stores all messages for this agent
368 history []AgentMessage
369
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700370 // Iterators add themselves here when they're ready to be notified of new messages.
371 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700372
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000373 // Track outstanding LLM call IDs
374 outstandingLLMCalls map[string]struct{}
375
376 // Track outstanding tool calls by ID with their names
377 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700378}
379
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700380// NewIterator implements CodingAgent.
381func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
382 a.mu.Lock()
383 defer a.mu.Unlock()
384
385 return &MessageIteratorImpl{
386 agent: a,
387 ctx: ctx,
388 nextMessageIdx: nextMessageIdx,
389 ch: make(chan *AgentMessage, 100),
390 }
391}
392
393type MessageIteratorImpl struct {
394 agent *Agent
395 ctx context.Context
396 nextMessageIdx int
397 ch chan *AgentMessage
398 subscribed bool
399}
400
401func (m *MessageIteratorImpl) Close() {
402 m.agent.mu.Lock()
403 defer m.agent.mu.Unlock()
404 // Delete ourselves from the subscribers list
405 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
406 return x == m.ch
407 })
408 close(m.ch)
409}
410
411func (m *MessageIteratorImpl) Next() *AgentMessage {
412 // We avoid subscription at creation to let ourselves catch up to "current state"
413 // before subscribing.
414 if !m.subscribed {
415 m.agent.mu.Lock()
416 if m.nextMessageIdx < len(m.agent.history) {
417 msg := &m.agent.history[m.nextMessageIdx]
418 m.nextMessageIdx++
419 m.agent.mu.Unlock()
420 return msg
421 }
422 // The next message doesn't exist yet, so let's subscribe
423 m.agent.subscribers = append(m.agent.subscribers, m.ch)
424 m.subscribed = true
425 m.agent.mu.Unlock()
426 }
427
428 for {
429 select {
430 case <-m.ctx.Done():
431 m.agent.mu.Lock()
432 // Delete ourselves from the subscribers list
433 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
434 return x == m.ch
435 })
436 m.subscribed = false
437 m.agent.mu.Unlock()
438 return nil
439 case msg, ok := <-m.ch:
440 if !ok {
441 // Close may have been called
442 return nil
443 }
444 if msg.Idx == m.nextMessageIdx {
445 m.nextMessageIdx++
446 return msg
447 }
448 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
449 panic("out of order message")
450 }
451 }
452}
453
Sean McCulloughd9d45812025-04-30 16:53:41 -0700454// Assert that Agent satisfies the CodingAgent interface.
455var _ CodingAgent = &Agent{}
456
457// StateName implements CodingAgent.
458func (a *Agent) CurrentStateName() string {
459 if a.stateMachine == nil {
460 return ""
461 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000462 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700463}
464
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700465// CurrentTodoContent returns the current todo list data as JSON.
466// It returns an empty string if no todos exist.
467func (a *Agent) CurrentTodoContent() string {
468 todoPath := claudetool.TodoFilePath(a.config.SessionID)
469 content, err := os.ReadFile(todoPath)
470 if err != nil {
471 return ""
472 }
473 return string(content)
474}
475
Earl Lee2e463fb2025-04-17 11:22:22 -0700476func (a *Agent) URL() string { return a.url }
477
478// Title returns the current title of the conversation.
479// If no title has been set, returns an empty string.
480func (a *Agent) Title() string {
481 a.mu.Lock()
482 defer a.mu.Unlock()
483 return a.title
484}
485
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000486// BranchName returns the git branch name for the conversation.
487func (a *Agent) BranchName() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700488 return a.gitState.BranchName()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000489}
490
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000491// OutstandingLLMCallCount returns the number of outstanding LLM calls.
492func (a *Agent) OutstandingLLMCallCount() int {
493 a.mu.Lock()
494 defer a.mu.Unlock()
495 return len(a.outstandingLLMCalls)
496}
497
498// OutstandingToolCalls returns the names of outstanding tool calls.
499func (a *Agent) OutstandingToolCalls() []string {
500 a.mu.Lock()
501 defer a.mu.Unlock()
502
503 tools := make([]string, 0, len(a.outstandingToolCalls))
504 for _, toolName := range a.outstandingToolCalls {
505 tools = append(tools, toolName)
506 }
507 return tools
508}
509
Earl Lee2e463fb2025-04-17 11:22:22 -0700510// OS returns the operating system of the client.
511func (a *Agent) OS() string {
512 return a.config.ClientGOOS
513}
514
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000515func (a *Agent) SessionID() string {
516 return a.config.SessionID
517}
518
Philip Zeyliger18532b22025-04-23 21:11:46 +0000519// OutsideOS returns the operating system of the outside system.
520func (a *Agent) OutsideOS() string {
521 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000522}
523
Philip Zeyliger18532b22025-04-23 21:11:46 +0000524// OutsideHostname returns the hostname of the outside system.
525func (a *Agent) OutsideHostname() string {
526 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000527}
528
Philip Zeyliger18532b22025-04-23 21:11:46 +0000529// OutsideWorkingDir returns the working directory on the outside system.
530func (a *Agent) OutsideWorkingDir() string {
531 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000532}
533
534// GitOrigin returns the URL of the git remote 'origin' if it exists.
535func (a *Agent) GitOrigin() string {
536 return a.gitOrigin
537}
538
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000539func (a *Agent) OpenBrowser(url string) {
540 if !a.IsInContainer() {
541 browser.Open(url)
542 return
543 }
544 // We're in Docker, need to send a request to the Git server
545 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700546 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000547 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700548 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000549 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700550 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000551 return
552 }
553 defer resp.Body.Close()
554 if resp.StatusCode == http.StatusOK {
555 return
556 }
557 body, _ := io.ReadAll(resp.Body)
558 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
559}
560
Sean McCullough96b60dd2025-04-30 09:49:10 -0700561// CurrentState returns the current state of the agent's state machine.
562func (a *Agent) CurrentState() State {
563 return a.stateMachine.CurrentState()
564}
565
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700566func (a *Agent) IsInContainer() bool {
567 return a.config.InDocker
568}
569
570func (a *Agent) FirstMessageIndex() int {
571 a.mu.Lock()
572 defer a.mu.Unlock()
573 return a.firstMessageIndex
574}
575
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000576// SetTitle sets the title of the conversation.
577func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700578 a.mu.Lock()
579 defer a.mu.Unlock()
580 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000581}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700582
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000583// SetBranch sets the branch name of the conversation.
584func (a *Agent) SetBranch(branchName string) {
585 a.mu.Lock()
586 defer a.mu.Unlock()
Philip Zeyligerf2872992025-05-22 10:35:28 -0700587 a.gitState.SetBranchName(branchName)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000588 convo, ok := a.convo.(*conversation.Convo)
589 if ok {
590 convo.ExtraData["branch"] = branchName
591 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700592}
593
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000594// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700595func (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 +0000596 // Track the tool call
597 a.mu.Lock()
598 a.outstandingToolCalls[id] = toolName
599 a.mu.Unlock()
600}
601
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700602// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
603// If there's only one element in the array and it's a text type, it returns that text directly.
604// It also processes nested ToolResult arrays recursively.
605func contentToString(contents []llm.Content) string {
606 if len(contents) == 0 {
607 return ""
608 }
609
610 // If there's only one element and it's a text type, return it directly
611 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
612 return contents[0].Text
613 }
614
615 // Otherwise, concatenate all text content
616 var result strings.Builder
617 for _, content := range contents {
618 if content.Type == llm.ContentTypeText {
619 result.WriteString(content.Text)
620 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
621 // Recursively process nested tool results
622 result.WriteString(contentToString(content.ToolResult))
623 }
624 }
625
626 return result.String()
627}
628
Earl Lee2e463fb2025-04-17 11:22:22 -0700629// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700630func (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 +0000631 // Remove the tool call from outstanding calls
632 a.mu.Lock()
633 delete(a.outstandingToolCalls, toolID)
634 a.mu.Unlock()
635
Earl Lee2e463fb2025-04-17 11:22:22 -0700636 m := AgentMessage{
637 Type: ToolUseMessageType,
638 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700639 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700640 ToolError: content.ToolError,
641 ToolName: toolName,
642 ToolInput: string(toolInput),
643 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700644 StartTime: content.ToolUseStartTime,
645 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700646 }
647
648 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700649 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
650 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700651 m.Elapsed = &elapsed
652 }
653
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700654 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700655 a.pushToOutbox(ctx, m)
656}
657
658// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700659func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000660 a.mu.Lock()
661 defer a.mu.Unlock()
662 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700663 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
664}
665
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700666// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700667// that need to be displayed (as well as tool calls that we send along when
668// they're done). (It would be reasonable to also mention tool calls when they're
669// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700670func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000671 // Remove the LLM call from outstanding calls
672 a.mu.Lock()
673 delete(a.outstandingLLMCalls, id)
674 a.mu.Unlock()
675
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700676 if resp == nil {
677 // LLM API call failed
678 m := AgentMessage{
679 Type: ErrorMessageType,
680 Content: "API call failed, type 'continue' to try again",
681 }
682 m.SetConvo(convo)
683 a.pushToOutbox(ctx, m)
684 return
685 }
686
Earl Lee2e463fb2025-04-17 11:22:22 -0700687 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700688 if convo.Parent == nil { // subconvos never end the turn
689 switch resp.StopReason {
690 case llm.StopReasonToolUse:
691 // Check whether any of the tool calls are for tools that should end the turn
692 ToolSearch:
693 for _, part := range resp.Content {
694 if part.Type != llm.ContentTypeToolUse {
695 continue
696 }
Sean McCullough021557a2025-05-05 23:20:53 +0000697 // Find the tool by name
698 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700699 if tool.Name == part.ToolName {
700 endOfTurn = tool.EndsTurn
701 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000702 }
703 }
Sean McCullough021557a2025-05-05 23:20:53 +0000704 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700705 default:
706 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000707 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700708 }
709 m := AgentMessage{
710 Type: AgentMessageType,
711 Content: collectTextContent(resp),
712 EndOfTurn: endOfTurn,
713 Usage: &resp.Usage,
714 StartTime: resp.StartTime,
715 EndTime: resp.EndTime,
716 }
717
718 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700719 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700720 var toolCalls []ToolCall
721 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700722 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700723 toolCalls = append(toolCalls, ToolCall{
724 Name: part.ToolName,
725 Input: string(part.ToolInput),
726 ToolCallId: part.ID,
727 })
728 }
729 }
730 m.ToolCalls = toolCalls
731 }
732
733 // Calculate the elapsed time if both start and end times are set
734 if resp.StartTime != nil && resp.EndTime != nil {
735 elapsed := resp.EndTime.Sub(*resp.StartTime)
736 m.Elapsed = &elapsed
737 }
738
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700739 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700740 a.pushToOutbox(ctx, m)
741}
742
743// WorkingDir implements CodingAgent.
744func (a *Agent) WorkingDir() string {
745 return a.workingDir
746}
747
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000748// RepoRoot returns the git repository root directory.
749func (a *Agent) RepoRoot() string {
750 return a.repoRoot
751}
752
Earl Lee2e463fb2025-04-17 11:22:22 -0700753// MessageCount implements CodingAgent.
754func (a *Agent) MessageCount() int {
755 a.mu.Lock()
756 defer a.mu.Unlock()
757 return len(a.history)
758}
759
760// Messages implements CodingAgent.
761func (a *Agent) Messages(start int, end int) []AgentMessage {
762 a.mu.Lock()
763 defer a.mu.Unlock()
764 return slices.Clone(a.history[start:end])
765}
766
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700767func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700768 return a.originalBudget
769}
770
771// AgentConfig contains configuration for creating a new Agent.
772type AgentConfig struct {
773 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700774 Service llm.Service
775 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700776 GitUsername string
777 GitEmail string
778 SessionID string
779 ClientGOOS string
780 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700781 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700782 UseAnthropicEdit bool
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000783 OneShot bool
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700784 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000785 // Outside information
786 OutsideHostname string
787 OutsideOS string
788 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700789
790 // Outtie's HTTP to, e.g., open a browser
791 OutsideHTTP string
792 // Outtie's Git server
793 GitRemoteAddr string
794 // Commit to checkout from Outtie
795 Commit string
Earl Lee2e463fb2025-04-17 11:22:22 -0700796}
797
798// NewAgent creates a new Agent.
799// It is not usable until Init() is called.
800func NewAgent(config AgentConfig) *Agent {
801 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -0700802 config: config,
803 ready: make(chan struct{}),
804 inbox: make(chan string, 100),
805 subscribers: make([]chan *AgentMessage, 0),
806 startedAt: time.Now(),
807 originalBudget: config.Budget,
808 gitState: AgentGitState{
809 seenCommits: make(map[string]bool),
810 gitRemoteAddr: config.GitRemoteAddr,
811 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000812 outsideHostname: config.OutsideHostname,
813 outsideOS: config.OutsideOS,
814 outsideWorkingDir: config.OutsideWorkingDir,
815 outstandingLLMCalls: make(map[string]struct{}),
816 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700817 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700818 workingDir: config.WorkingDir,
819 outsideHTTP: config.OutsideHTTP,
Earl Lee2e463fb2025-04-17 11:22:22 -0700820 }
821 return agent
822}
823
824type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700825 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -0700826
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700827 InDocker bool
828 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -0700829}
830
831func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700832 if a.convo != nil {
833 return fmt.Errorf("Agent.Init: already initialized")
834 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700835 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -0700836 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700837
Philip Zeyligerf2872992025-05-22 10:35:28 -0700838 // If a remote git addr was specified, we configure the remote
839 if a.gitState.gitRemoteAddr != "" {
840 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
841 cmd := exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", a.gitState.gitRemoteAddr)
842 cmd.Dir = a.workingDir
843 if out, err := cmd.CombinedOutput(); err != nil {
844 return fmt.Errorf("git remote add: %s: %v", out, err)
845 }
846 // sketch-host is a git repo hosted by "outtie sketch". When it notices a 'git fetch',
847 // it runs "git fetch" underneath the covers to get its latest commits. By configuring
848 // an additional remote.sketch-host.fetch, we make "origin/main" on innie sketch look like
849 // origin/main on outtie sketch, which should make it easier to rebase.
850 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.sketch-host.fetch",
851 "+refs/heads/feature/*:refs/remotes/origin/feature/*")
852 cmd.Dir = a.workingDir
853 if out, err := cmd.CombinedOutput(); err != nil {
854 return fmt.Errorf("git config --add: %s: %v", out, err)
855 }
856 }
857
858 // If a commit was specified, we fetch and reset to it.
859 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -0700860 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
861
Earl Lee2e463fb2025-04-17 11:22:22 -0700862 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700863 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -0700864 if out, err := cmd.CombinedOutput(); err != nil {
865 return fmt.Errorf("git stash: %s: %v", out, err)
866 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000867 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700868 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -0700869 if out, err := cmd.CombinedOutput(); err != nil {
870 return fmt.Errorf("git fetch: %s: %w", out, err)
871 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700872 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
873 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100874 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
875 // Remove git hooks if they exist and retry
876 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700877 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +0100878 if _, statErr := os.Stat(hookPath); statErr == nil {
879 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
880 slog.String("error", err.Error()),
881 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700882 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +0100883 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
884 }
885
886 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700887 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
888 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100889 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700890 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 +0100891 }
892 } else {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700893 return fmt.Errorf("git checkout %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +0100894 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700895 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700896 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700897
898 if ini.HostAddr != "" {
899 a.url = "http://" + ini.HostAddr
900 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700901
902 if !ini.NoGit {
903 repoRoot, err := repoRoot(ctx, a.workingDir)
904 if err != nil {
905 return fmt.Errorf("repoRoot: %w", err)
906 }
907 a.repoRoot = repoRoot
908
Earl Lee2e463fb2025-04-17 11:22:22 -0700909 if err != nil {
910 return fmt.Errorf("resolveRef: %w", err)
911 }
Philip Zeyliger49edc922025-05-14 09:45:45 -0700912
Josh Bleecher Snyder90993a02025-05-28 18:15:15 -0700913 if err := setupGitHooks(a.repoRoot); err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700914 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
915 }
916
Philip Zeyliger49edc922025-05-14 09:45:45 -0700917 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
918 cmd.Dir = repoRoot
919 if out, err := cmd.CombinedOutput(); err != nil {
920 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
921 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700922
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +0000923 slog.Info("running codebase analysis")
924 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
925 if err != nil {
926 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000927 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +0000928 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000929
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +0000930 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -0700931 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000932 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700933 }
934 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000935
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700936 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700937 }
Philip Zeyligerf2872992025-05-22 10:35:28 -0700938 a.gitState.lastHEAD = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -0700939 a.convo = a.initConvo()
940 close(a.ready)
941 return nil
942}
943
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700944//go:embed agent_system_prompt.txt
945var agentSystemPrompt string
946
Earl Lee2e463fb2025-04-17 11:22:22 -0700947// initConvo initializes the conversation.
948// It must not be called until all agent fields are initialized,
949// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700950func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700951 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700952 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700953 convo.PromptCaching = true
954 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000955 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000956 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -0700957
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000958 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
959 bashPermissionCheck := func(command string) error {
960 // Check if branch name is set
961 a.mu.Lock()
Philip Zeyligerf2872992025-05-22 10:35:28 -0700962 branchSet := a.gitState.BranchName() != ""
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000963 a.mu.Unlock()
964
965 // If branch is set, all commands are allowed
966 if branchSet {
967 return nil
968 }
969
970 // If branch is not set, check if this is a git commit command
971 willCommit, err := bashkit.WillRunGitCommit(command)
972 if err != nil {
973 // If there's an error checking, we should allow the command to proceed
974 return nil
975 }
976
977 // If it's a git commit and branch is not set, return an error
978 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000979 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000980 }
981
982 return nil
983 }
984
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000985 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000986
Earl Lee2e463fb2025-04-17 11:22:22 -0700987 // Register all tools with the conversation
988 // When adding, removing, or modifying tools here, double-check that the termui tool display
989 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000990
991 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -0700992 _, supportsScreenshots := a.config.Service.(*ant.Service)
993 var bTools []*llm.Tool
994 var browserCleanup func()
995
996 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
997 // Add cleanup function to context cancel
998 go func() {
999 <-a.config.Context.Done()
1000 browserCleanup()
1001 }()
1002 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001003
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001004 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001005 bashTool, claudetool.Keyword,
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001006 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001007 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001008 }
1009
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001010 // One-shot mode is non-interactive, multiple choice requires human response
1011 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001012 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001013 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001014
1015 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -07001016 if a.config.UseAnthropicEdit {
1017 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
1018 } else {
1019 convo.Tools = append(convo.Tools, claudetool.Patch)
1020 }
1021 convo.Listener = a
1022 return convo
1023}
1024
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001025var multipleChoiceTool = &llm.Tool{
1026 Name: "multiplechoice",
1027 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.",
1028 EndsTurn: true,
1029 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001030 "type": "object",
1031 "description": "The question and a list of answers you would expect the user to choose from.",
1032 "properties": {
1033 "question": {
1034 "type": "string",
1035 "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?'"
1036 },
1037 "responseOptions": {
1038 "type": "array",
1039 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1040 "items": {
1041 "type": "object",
1042 "properties": {
1043 "caption": {
1044 "type": "string",
1045 "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'"
1046 },
1047 "responseText": {
1048 "type": "string",
1049 "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'"
1050 }
1051 },
1052 "required": ["caption", "responseText"]
1053 }
1054 }
1055 },
1056 "required": ["question", "responseOptions"]
1057}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001058 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1059 // The Run logic for "multiplechoice" tool is a no-op on the server.
1060 // The UI will present a list of options for the user to select from,
1061 // and that's it as far as "executing" the tool_use goes.
1062 // When the user *does* select one of the presented options, that
1063 // responseText gets sent as a chat message on behalf of the user.
1064 return llm.TextContent("end your turn and wait for the user to respond"), nil
1065 },
Sean McCullough485afc62025-04-28 14:28:39 -07001066}
1067
1068type MultipleChoiceOption struct {
1069 Caption string `json:"caption"`
1070 ResponseText string `json:"responseText"`
1071}
1072
1073type MultipleChoiceParams struct {
1074 Question string `json:"question"`
1075 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1076}
1077
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001078// branchExists reports whether branchName exists, either locally or in well-known remotes.
1079func branchExists(dir, branchName string) bool {
1080 refs := []string{
1081 "refs/heads/",
1082 "refs/remotes/origin/",
1083 "refs/remotes/sketch-host/",
1084 }
1085 for _, ref := range refs {
1086 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1087 cmd.Dir = dir
1088 if cmd.Run() == nil { // exit code 0 means branch exists
1089 return true
1090 }
1091 }
1092 return false
1093}
1094
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001095func (a *Agent) titleTool() *llm.Tool {
1096 description := `Sets the conversation title.`
1097 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001098 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001099 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001100 InputSchema: json.RawMessage(`{
1101 "type": "object",
1102 "properties": {
1103 "title": {
1104 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001105 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001106 }
1107 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001108 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001109}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001110 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001111 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001112 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001113 }
1114 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001115 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001116 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001117
1118 // We don't allow changing the title once set to be consistent with the previous behavior
1119 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001120 t := a.Title()
1121 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001122 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001123 }
1124
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001125 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001126 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001127 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001128
1129 a.SetTitle(params.Title)
1130 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001131 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001132 },
1133 }
1134 return titleTool
1135}
1136
1137func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001138 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 +00001139 preCommit := &llm.Tool{
1140 Name: "precommit",
1141 Description: description,
1142 InputSchema: json.RawMessage(`{
1143 "type": "object",
1144 "properties": {
1145 "branch_name": {
1146 "type": "string",
1147 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1148 }
1149 },
1150 "required": ["branch_name"]
1151}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001152 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001153 var params struct {
1154 BranchName string `json:"branch_name"`
1155 }
1156 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001157 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001158 }
1159
1160 b := a.BranchName()
1161 if b != "" {
Josh Bleecher Snyder44d1f1a2025-05-12 19:18:32 -07001162 return nil, fmt.Errorf("branch already set to %s; do not create a new branch", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001163 }
1164
1165 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001166 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001167 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001168 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001169 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001170 }
1171 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001172 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001173 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001174 }
1175
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001176 a.SetBranch(branchName)
Josh Bleecher Snyderf7bebdd2025-05-14 15:22:24 -07001177 response := fmt.Sprintf("switched to branch sketch/%q - DO NOT change branches unless explicitly requested", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001178
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001179 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1180 if err != nil {
1181 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1182 }
1183 if len(styleHint) > 0 {
1184 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001185 }
1186
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001187 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001188 },
1189 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001190 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001191}
1192
1193func (a *Agent) Ready() <-chan struct{} {
1194 return a.ready
1195}
1196
1197func (a *Agent) UserMessage(ctx context.Context, msg string) {
1198 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1199 a.inbox <- msg
1200}
1201
Earl Lee2e463fb2025-04-17 11:22:22 -07001202func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1203 return a.convo.CancelToolUse(toolUseID, cause)
1204}
1205
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001206func (a *Agent) CancelTurn(cause error) {
1207 a.cancelTurnMu.Lock()
1208 defer a.cancelTurnMu.Unlock()
1209 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001210 // Force state transition to cancelled state
1211 ctx := a.config.Context
1212 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001213 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001214 }
1215}
1216
1217func (a *Agent) Loop(ctxOuter context.Context) {
1218 for {
1219 select {
1220 case <-ctxOuter.Done():
1221 return
1222 default:
1223 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001224 a.cancelTurnMu.Lock()
1225 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001226 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001227 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001228 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001229 a.cancelTurn = cancel
1230 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001231 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1232 if err != nil {
1233 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1234 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001235 cancel(nil)
1236 }
1237 }
1238}
1239
1240func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1241 if m.Timestamp.IsZero() {
1242 m.Timestamp = time.Now()
1243 }
1244
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001245 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1246 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1247 m.Content = m.ToolResult
1248 }
1249
Earl Lee2e463fb2025-04-17 11:22:22 -07001250 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1251 if m.EndOfTurn && m.Type == AgentMessageType {
1252 turnDuration := time.Since(a.startOfTurn)
1253 m.TurnDuration = &turnDuration
1254 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1255 }
1256
Earl Lee2e463fb2025-04-17 11:22:22 -07001257 a.mu.Lock()
1258 defer a.mu.Unlock()
1259 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001260 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001261 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001262
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001263 // Notify all subscribers
1264 for _, ch := range a.subscribers {
1265 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001266 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001267}
1268
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001269func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1270 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001271 if block {
1272 select {
1273 case <-ctx.Done():
1274 return m, ctx.Err()
1275 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001276 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001277 }
1278 }
1279 for {
1280 select {
1281 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001282 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001283 default:
1284 return m, nil
1285 }
1286 }
1287}
1288
Sean McCullough885a16a2025-04-30 02:49:25 +00001289// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001290func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001291 // Reset the start of turn time
1292 a.startOfTurn = time.Now()
1293
Sean McCullough96b60dd2025-04-30 09:49:10 -07001294 // Transition to waiting for user input state
1295 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1296
Sean McCullough885a16a2025-04-30 02:49:25 +00001297 // Process initial user message
1298 initialResp, err := a.processUserMessage(ctx)
1299 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001300 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001301 return err
1302 }
1303
1304 // Handle edge case where both initialResp and err are nil
1305 if initialResp == nil {
1306 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001307 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1308
Sean McCullough9f4b8082025-04-30 17:34:07 +00001309 a.pushToOutbox(ctx, errorMessage(err))
1310 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001311 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001312
Earl Lee2e463fb2025-04-17 11:22:22 -07001313 // We do this as we go, but let's also do it at the end of the turn
1314 defer func() {
1315 if _, err := a.handleGitCommits(ctx); err != nil {
1316 // Just log the error, don't stop execution
1317 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1318 }
1319 }()
1320
Sean McCullougha1e0e492025-05-01 10:51:08 -07001321 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001322 resp := initialResp
1323 for {
1324 // Check if we are over budget
1325 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001326 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001327 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001328 }
1329
1330 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001331 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001332 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001333 break
1334 }
1335
Sean McCullough96b60dd2025-04-30 09:49:10 -07001336 // Transition to tool use requested state
1337 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1338
Sean McCullough885a16a2025-04-30 02:49:25 +00001339 // Handle tool execution
1340 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1341 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001342 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001343 }
1344
Sean McCullougha1e0e492025-05-01 10:51:08 -07001345 if toolResp == nil {
1346 return fmt.Errorf("cannot continue conversation with a nil tool response")
1347 }
1348
Sean McCullough885a16a2025-04-30 02:49:25 +00001349 // Set the response for the next iteration
1350 resp = toolResp
1351 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001352
1353 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001354}
1355
1356// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001357func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001358 // Wait for at least one message from the user
1359 msgs, err := a.GatherMessages(ctx, true)
1360 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001361 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001362 return nil, err
1363 }
1364
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001365 userMessage := llm.Message{
1366 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001367 Content: msgs,
1368 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001369
Sean McCullough96b60dd2025-04-30 09:49:10 -07001370 // Transition to sending to LLM state
1371 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1372
Sean McCullough885a16a2025-04-30 02:49:25 +00001373 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001374 resp, err := a.convo.SendMessage(userMessage)
1375 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001376 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001377 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001378 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001379 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001380
Sean McCullough96b60dd2025-04-30 09:49:10 -07001381 // Transition to processing LLM response state
1382 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1383
Sean McCullough885a16a2025-04-30 02:49:25 +00001384 return resp, nil
1385}
1386
1387// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001388func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1389 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001390 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001391 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001392
Sean McCullough96b60dd2025-04-30 09:49:10 -07001393 // Transition to checking for cancellation state
1394 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1395
Sean McCullough885a16a2025-04-30 02:49:25 +00001396 // Check if the operation was cancelled by the user
1397 select {
1398 case <-ctx.Done():
1399 // Don't actually run any of the tools, but rather build a response
1400 // for each tool_use message letting the LLM know that user canceled it.
1401 var err error
1402 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001403 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001404 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001405 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001406 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001407 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001408 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001409 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001410 // Transition to running tool state
1411 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1412
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001413 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001414 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001415 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001416
1417 // Execute the tools
1418 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001419 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001420 if ctx.Err() != nil { // e.g. the user canceled the operation
1421 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001422 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001423 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001424 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001425 a.pushToOutbox(ctx, errorMessage(err))
1426 }
1427 }
1428
1429 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001430 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001431 autoqualityMessages := a.processGitChanges(ctx)
1432
1433 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001434 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001435 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001436 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001437 return false, nil
1438 }
1439
1440 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001441 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1442 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001443}
1444
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001445// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001446func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001447 // Check for git commits
1448 _, err := a.handleGitCommits(ctx)
1449 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001450 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001451 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001452 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001453 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001454}
1455
1456// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1457// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001458func (a *Agent) processGitChanges(ctx context.Context) []string {
1459 // Check for git commits after tool execution
1460 newCommits, err := a.handleGitCommits(ctx)
1461 if err != nil {
1462 // Just log the error, don't stop execution
1463 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1464 return nil
1465 }
1466
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001467 // Run mechanical checks if there was exactly one new commit.
1468 if len(newCommits) != 1 {
1469 return nil
1470 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001471 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001472 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1473 msg := a.codereview.RunMechanicalChecks(ctx)
1474 if msg != "" {
1475 a.pushToOutbox(ctx, AgentMessage{
1476 Type: AutoMessageType,
1477 Content: msg,
1478 Timestamp: time.Now(),
1479 })
1480 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001481 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001482
1483 return autoqualityMessages
1484}
1485
1486// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001487func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001488 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001489 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001490 msgs, err := a.GatherMessages(ctx, false)
1491 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001492 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001493 return false, nil
1494 }
1495
1496 // Inject any auto-generated messages from quality checks
1497 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001498 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001499 }
1500
1501 // Handle cancellation by appending a message about it
1502 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001503 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001504 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001505 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001506 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1507 } else if err := a.convo.OverBudget(); err != nil {
1508 // Handle budget issues by appending a message about it
1509 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 -07001510 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001511 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1512 }
1513
1514 // Combine tool results with user messages
1515 results = append(results, msgs...)
1516
1517 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001518 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001519 resp, err := a.convo.SendMessage(llm.Message{
1520 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001521 Content: results,
1522 })
1523 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001524 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001525 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1526 return true, nil // Return true to continue the conversation, but with no response
1527 }
1528
Sean McCullough96b60dd2025-04-30 09:49:10 -07001529 // Transition back to processing LLM response
1530 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1531
Sean McCullough885a16a2025-04-30 02:49:25 +00001532 if cancelled {
1533 return false, nil
1534 }
1535
1536 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001537}
1538
1539func (a *Agent) overBudget(ctx context.Context) error {
1540 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001541 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001542 m := budgetMessage(err)
1543 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001544 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001545 a.convo.ResetBudget(a.originalBudget)
1546 return err
1547 }
1548 return nil
1549}
1550
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001551func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001552 // Collect all text content
1553 var allText strings.Builder
1554 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001555 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001556 if allText.Len() > 0 {
1557 allText.WriteString("\n\n")
1558 }
1559 allText.WriteString(content.Text)
1560 }
1561 }
1562 return allText.String()
1563}
1564
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001565func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001566 a.mu.Lock()
1567 defer a.mu.Unlock()
1568 return a.convo.CumulativeUsage()
1569}
1570
Earl Lee2e463fb2025-04-17 11:22:22 -07001571// Diff returns a unified diff of changes made since the agent was instantiated.
1572func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001573 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001574 return "", fmt.Errorf("no initial commit reference available")
1575 }
1576
1577 // Find the repository root
1578 ctx := context.Background()
1579
1580 // If a specific commit hash is provided, show just that commit's changes
1581 if commit != nil && *commit != "" {
1582 // Validate that the commit looks like a valid git SHA
1583 if !isValidGitSHA(*commit) {
1584 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1585 }
1586
1587 // Get the diff for just this commit
1588 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1589 cmd.Dir = a.repoRoot
1590 output, err := cmd.CombinedOutput()
1591 if err != nil {
1592 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1593 }
1594 return string(output), nil
1595 }
1596
1597 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001598 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001599 cmd.Dir = a.repoRoot
1600 output, err := cmd.CombinedOutput()
1601 if err != nil {
1602 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1603 }
1604
1605 return string(output), nil
1606}
1607
Philip Zeyliger49edc922025-05-14 09:45:45 -07001608// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1609// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1610func (a *Agent) SketchGitBaseRef() string {
1611 if a.IsInContainer() {
1612 return "sketch-base"
1613 } else {
1614 return "sketch-base-" + a.SessionID()
1615 }
1616}
1617
1618// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1619func (a *Agent) SketchGitBase() string {
1620 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1621 cmd.Dir = a.repoRoot
1622 output, err := cmd.CombinedOutput()
1623 if err != nil {
1624 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1625 return "HEAD"
1626 }
1627 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001628}
1629
Pokey Rule7a113622025-05-12 10:58:45 +01001630// removeGitHooks removes the Git hooks directory from the repository
1631func removeGitHooks(_ context.Context, repoPath string) error {
1632 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1633
1634 // Check if hooks directory exists
1635 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1636 // Directory doesn't exist, nothing to do
1637 return nil
1638 }
1639
1640 // Remove the hooks directory
1641 err := os.RemoveAll(hooksDir)
1642 if err != nil {
1643 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1644 }
1645
1646 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001647 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001648 if err != nil {
1649 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1650 }
1651
1652 return nil
1653}
1654
Philip Zeyligerf2872992025-05-22 10:35:28 -07001655func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1656 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef())
1657 for _, msg := range msgs {
1658 a.pushToOutbox(ctx, msg)
1659 }
1660 return commits, error
1661}
1662
Earl Lee2e463fb2025-04-17 11:22:22 -07001663// handleGitCommits() highlights new commits to the user. When running
1664// under docker, new HEADs are pushed to a branch according to the title.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001665func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string) ([]AgentMessage, []*GitCommit, error) {
1666 ags.mu.Lock()
1667 defer ags.mu.Unlock()
1668
1669 msgs := []AgentMessage{}
1670 if repoRoot == "" {
1671 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001672 }
1673
Philip Zeyligerf2872992025-05-22 10:35:28 -07001674 head, err := resolveRef(ctx, repoRoot, "HEAD")
Earl Lee2e463fb2025-04-17 11:22:22 -07001675 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001676 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001677 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001678 if head == ags.lastHEAD {
1679 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001680 }
1681 defer func() {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001682 ags.lastHEAD = head
Earl Lee2e463fb2025-04-17 11:22:22 -07001683 }()
1684
1685 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1686 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1687 // to the last 100 commits.
1688 var commits []*GitCommit
1689
1690 // Get commits since the initial commit
1691 // Format: <hash>\0<subject>\0<body>\0
1692 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1693 // Limit to 100 commits to avoid overwhelming the user
Philip Zeyligerf2872992025-05-22 10:35:28 -07001694 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, head)
1695 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001696 output, err := cmd.Output()
1697 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001698 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001699 }
1700
1701 // Parse git log output and filter out already seen commits
1702 parsedCommits := parseGitLog(string(output))
1703
1704 var headCommit *GitCommit
1705
1706 // Filter out commits we've already seen
1707 for _, commit := range parsedCommits {
1708 if commit.Hash == head {
1709 headCommit = &commit
1710 }
1711
1712 // Skip if we've seen this commit before. If our head has changed, always include that.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001713 if ags.seenCommits[commit.Hash] && commit.Hash != head {
Earl Lee2e463fb2025-04-17 11:22:22 -07001714 continue
1715 }
1716
1717 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001718 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001719
1720 // Add to our list of new commits
1721 commits = append(commits, &commit)
1722 }
1723
Philip Zeyligerf2872992025-05-22 10:35:28 -07001724 if ags.gitRemoteAddr != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001725 if headCommit == nil {
1726 // I think this can only happen if we have a bug or if there's a race.
1727 headCommit = &GitCommit{}
1728 headCommit.Hash = head
1729 headCommit.Subject = "unknown"
1730 commits = append(commits, headCommit)
1731 }
1732
Philip Zeyligerf2872992025-05-22 10:35:28 -07001733 originalBranch := cmp.Or(ags.branchName, "sketch/"+sessionID)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001734 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001735
1736 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1737 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1738 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001739
1740 // Try up to 10 times with different branch names if the branch is checked out on the remote
1741 var out []byte
1742 var err error
1743 for retries := range 10 {
1744 if retries > 0 {
1745 // Add a numeric suffix to the branch name
1746 branch = fmt.Sprintf("%s%d", originalBranch, retries)
1747 }
1748
Philip Zeyligerf2872992025-05-22 10:35:28 -07001749 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1750 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001751 out, err = cmd.CombinedOutput()
1752
1753 if err == nil {
1754 // Success! Break out of the retry loop
1755 break
1756 }
1757
1758 // Check if this is the "refusing to update checked out branch" error
1759 if !strings.Contains(string(out), "refusing to update checked out branch") {
1760 // This is a different error, so don't retry
1761 break
1762 }
1763
1764 // If we're on the last retry, we'll report the error
1765 if retries == 9 {
1766 break
1767 }
1768 }
1769
1770 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001771 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001772 } else {
1773 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001774 // Update the agent's branch name if we ended up using a different one
1775 if branch != originalBranch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001776 ags.branchName = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001777 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001778 }
1779 }
1780
1781 // If we found new commits, create a message
1782 if len(commits) > 0 {
1783 msg := AgentMessage{
1784 Type: CommitMessageType,
1785 Timestamp: time.Now(),
1786 Commits: commits,
1787 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001788 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001789 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001790 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001791}
1792
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001793func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001794 return strings.Map(func(r rune) rune {
1795 // lowercase
1796 if r >= 'A' && r <= 'Z' {
1797 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001798 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001799 // replace spaces with dashes
1800 if r == ' ' {
1801 return '-'
1802 }
1803 // allow alphanumerics and dashes
1804 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1805 return r
1806 }
1807 return -1
1808 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001809}
1810
1811// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1812// and returns an array of GitCommit structs.
1813func parseGitLog(output string) []GitCommit {
1814 var commits []GitCommit
1815
1816 // No output means no commits
1817 if len(output) == 0 {
1818 return commits
1819 }
1820
1821 // Split by NULL byte
1822 parts := strings.Split(output, "\x00")
1823
1824 // Process in triplets (hash, subject, body)
1825 for i := 0; i < len(parts); i++ {
1826 // Skip empty parts
1827 if parts[i] == "" {
1828 continue
1829 }
1830
1831 // This should be a hash
1832 hash := strings.TrimSpace(parts[i])
1833
1834 // Make sure we have at least a subject part available
1835 if i+1 >= len(parts) {
1836 break // No more parts available
1837 }
1838
1839 // Get the subject
1840 subject := strings.TrimSpace(parts[i+1])
1841
1842 // Get the body if available
1843 body := ""
1844 if i+2 < len(parts) {
1845 body = strings.TrimSpace(parts[i+2])
1846 }
1847
1848 // Skip to the next triplet
1849 i += 2
1850
1851 commits = append(commits, GitCommit{
1852 Hash: hash,
1853 Subject: subject,
1854 Body: body,
1855 })
1856 }
1857
1858 return commits
1859}
1860
1861func repoRoot(ctx context.Context, dir string) (string, error) {
1862 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1863 stderr := new(strings.Builder)
1864 cmd.Stderr = stderr
1865 cmd.Dir = dir
1866 out, err := cmd.Output()
1867 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001868 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07001869 }
1870 return strings.TrimSpace(string(out)), nil
1871}
1872
1873func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1874 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1875 stderr := new(strings.Builder)
1876 cmd.Stderr = stderr
1877 cmd.Dir = dir
1878 out, err := cmd.Output()
1879 if err != nil {
1880 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1881 }
1882 // TODO: validate that out is valid hex
1883 return strings.TrimSpace(string(out)), nil
1884}
1885
1886// isValidGitSHA validates if a string looks like a valid git SHA hash.
1887// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1888func isValidGitSHA(sha string) bool {
1889 // Git SHA must be a hexadecimal string with at least 4 characters
1890 if len(sha) < 4 || len(sha) > 40 {
1891 return false
1892 }
1893
1894 // Check if the string only contains hexadecimal characters
1895 for _, char := range sha {
1896 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1897 return false
1898 }
1899 }
1900
1901 return true
1902}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001903
1904// getGitOrigin returns the URL of the git remote 'origin' if it exists
1905func getGitOrigin(ctx context.Context, dir string) string {
1906 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1907 cmd.Dir = dir
1908 stderr := new(strings.Builder)
1909 cmd.Stderr = stderr
1910 out, err := cmd.Output()
1911 if err != nil {
1912 return ""
1913 }
1914 return strings.TrimSpace(string(out))
1915}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001916
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001917// systemPromptData contains the data used to render the system prompt template
1918type systemPromptData struct {
1919 EditPrompt string
1920 ClientGOOS string
1921 ClientGOARCH string
1922 WorkingDir string
1923 RepoRoot string
1924 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001925 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001926}
1927
1928// renderSystemPrompt renders the system prompt template.
1929func (a *Agent) renderSystemPrompt() string {
1930 // Determine the appropriate edit prompt based on config
1931 var editPrompt string
1932 if a.config.UseAnthropicEdit {
1933 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."
1934 } else {
1935 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1936 }
1937
1938 data := systemPromptData{
1939 EditPrompt: editPrompt,
1940 ClientGOOS: a.config.ClientGOOS,
1941 ClientGOARCH: a.config.ClientGOARCH,
1942 WorkingDir: a.workingDir,
1943 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07001944 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001945 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001946 }
1947
1948 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1949 if err != nil {
1950 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1951 }
1952 buf := new(strings.Builder)
1953 err = tmpl.Execute(buf, data)
1954 if err != nil {
1955 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1956 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001957 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001958 return buf.String()
1959}
Philip Zeyligereab12de2025-05-14 02:35:53 +00001960
1961// StateTransitionIterator provides an iterator over state transitions.
1962type StateTransitionIterator interface {
1963 // Next blocks until a new state transition is available or context is done.
1964 // Returns nil if the context is cancelled.
1965 Next() *StateTransition
1966 // Close removes the listener and cleans up resources.
1967 Close()
1968}
1969
1970// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
1971type StateTransitionIteratorImpl struct {
1972 agent *Agent
1973 ctx context.Context
1974 ch chan StateTransition
1975 unsubscribe func()
1976}
1977
1978// Next blocks until a new state transition is available or the context is cancelled.
1979func (s *StateTransitionIteratorImpl) Next() *StateTransition {
1980 select {
1981 case <-s.ctx.Done():
1982 return nil
1983 case transition, ok := <-s.ch:
1984 if !ok {
1985 return nil
1986 }
1987 transitionCopy := transition
1988 return &transitionCopy
1989 }
1990}
1991
1992// Close removes the listener and cleans up resources.
1993func (s *StateTransitionIteratorImpl) Close() {
1994 if s.unsubscribe != nil {
1995 s.unsubscribe()
1996 s.unsubscribe = nil
1997 }
1998}
1999
2000// NewStateTransitionIterator returns an iterator that receives state transitions.
2001func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2002 a.mu.Lock()
2003 defer a.mu.Unlock()
2004
2005 // Create channel to receive state transitions
2006 ch := make(chan StateTransition, 10)
2007
2008 // Add a listener to the state machine
2009 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2010
2011 return &StateTransitionIteratorImpl{
2012 agent: a,
2013 ctx: ctx,
2014 ch: ch,
2015 unsubscribe: unsubscribe,
2016 }
2017}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002018
2019// setupGitHooks creates or updates git hooks in the specified working directory.
2020func setupGitHooks(workingDir string) error {
2021 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2022
2023 _, err := os.Stat(hooksDir)
2024 if os.IsNotExist(err) {
2025 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2026 }
2027 if err != nil {
2028 return fmt.Errorf("error checking git hooks directory: %w", err)
2029 }
2030
2031 // Define the post-commit hook content
2032 postCommitHook := `#!/bin/bash
2033echo "<post_commit_hook>"
2034echo "Please review this commit message and fix it if it is incorrect."
2035echo "This hook only echos the commit message; it does not modify it."
2036echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2037echo "<last_commit_message>"
2038git log -1 --pretty=%B
2039echo "</last_commit_message>"
2040echo "</post_commit_hook>"
2041`
2042
2043 // Define the prepare-commit-msg hook content
2044 prepareCommitMsgHook := `#!/bin/bash
2045# Add Co-Authored-By and Change-ID trailers to commit messages
2046# Check if these trailers already exist before adding them
2047
2048commit_file="$1"
2049COMMIT_SOURCE="$2"
2050
2051# Skip for merges, squashes, or when using a commit template
2052if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2053 [ "$COMMIT_SOURCE" = "squash" ]; then
2054 exit 0
2055fi
2056
2057commit_msg=$(cat "$commit_file")
2058
2059needs_co_author=true
2060needs_change_id=true
2061
2062# Check if commit message already has Co-Authored-By trailer
2063if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2064 needs_co_author=false
2065fi
2066
2067# Check if commit message already has Change-ID trailer
2068if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2069 needs_change_id=false
2070fi
2071
2072# Only modify if at least one trailer needs to be added
2073if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002074 # Ensure there's a proper blank line before trailers
2075 if [ -s "$commit_file" ]; then
2076 # Check if file ends with newline by reading last character
2077 last_char=$(tail -c 1 "$commit_file")
2078
2079 if [ "$last_char" != "" ]; then
2080 # File doesn't end with newline - add two newlines (complete line + blank line)
2081 echo "" >> "$commit_file"
2082 echo "" >> "$commit_file"
2083 else
2084 # File ends with newline - check if we already have a blank line
2085 last_line=$(tail -1 "$commit_file")
2086 if [ -n "$last_line" ]; then
2087 # Last line has content - add one newline for blank line
2088 echo "" >> "$commit_file"
2089 fi
2090 # If last line is empty, we already have a blank line - don't add anything
2091 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002092 fi
2093
2094 # Add trailers if needed
2095 if [ "$needs_co_author" = true ]; then
2096 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2097 fi
2098
2099 if [ "$needs_change_id" = true ]; then
2100 change_id=$(openssl rand -hex 8)
2101 echo "Change-ID: s${change_id}k" >> "$commit_file"
2102 fi
2103fi
2104`
2105
2106 // Update or create the post-commit hook
2107 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2108 if err != nil {
2109 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2110 }
2111
2112 // Update or create the prepare-commit-msg hook
2113 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2114 if err != nil {
2115 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2116 }
2117
2118 return nil
2119}
2120
2121// updateOrCreateHook creates a new hook file or updates an existing one
2122// by appending the new content if it doesn't already contain it.
2123func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2124 // Check if the hook already exists
2125 buf, err := os.ReadFile(hookPath)
2126 if os.IsNotExist(err) {
2127 // Hook doesn't exist, create it
2128 err = os.WriteFile(hookPath, []byte(content), 0o755)
2129 if err != nil {
2130 return fmt.Errorf("failed to create hook: %w", err)
2131 }
2132 return nil
2133 }
2134 if err != nil {
2135 return fmt.Errorf("error reading existing hook: %w", err)
2136 }
2137
2138 // Hook exists, check if our content is already in it by looking for a distinctive line
2139 code := string(buf)
2140 if strings.Contains(code, distinctiveLine) {
2141 // Already contains our content, nothing to do
2142 return nil
2143 }
2144
2145 // Append our content to the existing hook
2146 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2147 if err != nil {
2148 return fmt.Errorf("failed to open hook for appending: %w", err)
2149 }
2150 defer f.Close()
2151
2152 // Ensure there's a newline at the end of the existing content if needed
2153 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2154 _, err = f.WriteString("\n")
2155 if err != nil {
2156 return fmt.Errorf("failed to add newline to hook: %w", err)
2157 }
2158 }
2159
2160 // Add a separator before our content
2161 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2162 if err != nil {
2163 return fmt.Errorf("failed to append to hook: %w", err)
2164 }
2165
2166 return nil
2167}