blob: 98925e24e10d9c3f88250d7c99e4e2510c4632d3 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package loop
2
3import (
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07004 "cmp"
Earl Lee2e463fb2025-04-17 11:22:22 -07005 "context"
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07006 _ "embed"
Earl Lee2e463fb2025-04-17 11:22:22 -07007 "encoding/json"
8 "fmt"
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +00009 "io"
Earl Lee2e463fb2025-04-17 11:22:22 -070010 "log/slog"
11 "net/http"
12 "os"
13 "os/exec"
Pokey Rule7a113622025-05-12 10:58:45 +010014 "path/filepath"
Earl Lee2e463fb2025-04-17 11:22:22 -070015 "runtime/debug"
16 "slices"
17 "strings"
18 "sync"
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +000019 "text/template"
Earl Lee2e463fb2025-04-17 11:22:22 -070020 "time"
21
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000022 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070023 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000024 "sketch.dev/claudetool/bashkit"
Autoformatter4962f152025-05-06 17:24:20 +000025 "sketch.dev/claudetool/browse"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000026 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydera997be62025-05-07 22:52:46 +000027 "sketch.dev/claudetool/onstart"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070028 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070029 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070030 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070031)
32
33const (
34 userCancelMessage = "user requested agent to stop handling responses"
35)
36
Philip Zeyligerb7c58752025-05-01 10:10:17 -070037type MessageIterator interface {
38 // Next blocks until the next message is available. It may
39 // return nil if the underlying iterator context is done.
40 Next() *AgentMessage
41 Close()
42}
43
Earl Lee2e463fb2025-04-17 11:22:22 -070044type CodingAgent interface {
45 // Init initializes an agent inside a docker container.
46 Init(AgentInit) error
47
48 // Ready returns a channel closed after Init successfully called.
49 Ready() <-chan struct{}
50
51 // URL reports the HTTP URL of this agent.
52 URL() string
53
54 // UserMessage enqueues a message to the agent and returns immediately.
55 UserMessage(ctx context.Context, msg string)
56
Philip Zeyligerb7c58752025-05-01 10:10:17 -070057 // Returns an iterator that finishes when the context is done and
58 // starts with the given message index.
59 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070060
Philip Zeyligereab12de2025-05-14 02:35:53 +000061 // Returns an iterator that notifies of state transitions until the context is done.
62 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
63
Earl Lee2e463fb2025-04-17 11:22:22 -070064 // Loop begins the agent loop returns only when ctx is cancelled.
65 Loop(ctx context.Context)
66
Sean McCulloughedc88dc2025-04-30 02:55:01 +000067 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070068
69 CancelToolUse(toolUseID string, cause error) error
70
71 // Returns a subset of the agent's message history.
72 Messages(start int, end int) []AgentMessage
73
74 // Returns the current number of messages in the history
75 MessageCount() int
76
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070077 TotalUsage() conversation.CumulativeUsage
78 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070079
Earl Lee2e463fb2025-04-17 11:22:22 -070080 WorkingDir() string
81
82 // Diff returns a unified diff of changes made since the agent was instantiated.
83 // If commit is non-nil, it shows the diff for just that specific commit.
84 Diff(commit *string) (string, error)
85
Philip Zeyliger49edc922025-05-14 09:45:45 -070086 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
87 // starts out as the commit where sketch started, but a user can move it if need
88 // be, for example in the case of a rebase. It is stored as a git tag.
89 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070090
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000091 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
92 // (Typically, this is "sketch-base")
93 SketchGitBaseRef() string
94
Earl Lee2e463fb2025-04-17 11:22:22 -070095 // Title returns the current title of the conversation.
96 Title() string
97
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000098 // BranchName returns the git branch name for the conversation.
99 BranchName() string
100
Earl Lee2e463fb2025-04-17 11:22:22 -0700101 // OS returns the operating system of the client.
102 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000103
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000104 // SessionID returns the unique session identifier.
105 SessionID() string
106
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000107 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
108 OutstandingLLMCallCount() int
109
110 // OutstandingToolCalls returns the names of outstanding tool calls.
111 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000112 OutsideOS() string
113 OutsideHostname() string
114 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000115 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000116 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
117 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700118
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700119 // IsInContainer returns true if the agent is running in a container
120 IsInContainer() bool
121 // FirstMessageIndex returns the index of the first message in the current conversation
122 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700123
124 CurrentStateName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700125}
126
127type CodingAgentMessageType string
128
129const (
130 UserMessageType CodingAgentMessageType = "user"
131 AgentMessageType CodingAgentMessageType = "agent"
132 ErrorMessageType CodingAgentMessageType = "error"
133 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
134 ToolUseMessageType CodingAgentMessageType = "tool"
135 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
136 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
137
138 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
139)
140
141type AgentMessage struct {
142 Type CodingAgentMessageType `json:"type"`
143 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
144 EndOfTurn bool `json:"end_of_turn"`
145
146 Content string `json:"content"`
147 ToolName string `json:"tool_name,omitempty"`
148 ToolInput string `json:"input,omitempty"`
149 ToolResult string `json:"tool_result,omitempty"`
150 ToolError bool `json:"tool_error,omitempty"`
151 ToolCallId string `json:"tool_call_id,omitempty"`
152
153 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
154 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
155
Sean McCulloughd9f13372025-04-21 15:08:49 -0700156 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
157 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
158
Earl Lee2e463fb2025-04-17 11:22:22 -0700159 // Commits is a list of git commits for a commit message
160 Commits []*GitCommit `json:"commits,omitempty"`
161
162 Timestamp time.Time `json:"timestamp"`
163 ConversationID string `json:"conversation_id"`
164 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700165 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700166
167 // Message timing information
168 StartTime *time.Time `json:"start_time,omitempty"`
169 EndTime *time.Time `json:"end_time,omitempty"`
170 Elapsed *time.Duration `json:"elapsed,omitempty"`
171
172 // Turn duration - the time taken for a complete agent turn
173 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
174
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000175 // HideOutput indicates that this message should not be rendered in the UI.
176 // This is useful for subconversations that generate output that shouldn't be shown to the user.
177 HideOutput bool `json:"hide_output,omitempty"`
178
Earl Lee2e463fb2025-04-17 11:22:22 -0700179 Idx int `json:"idx"`
180}
181
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000182// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700183func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700184 if convo == nil {
185 m.ConversationID = ""
186 m.ParentConversationID = nil
187 return
188 }
189 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000190 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700191 if convo.Parent != nil {
192 m.ParentConversationID = &convo.Parent.ID
193 }
194}
195
Earl Lee2e463fb2025-04-17 11:22:22 -0700196// GitCommit represents a single git commit for a commit message
197type GitCommit struct {
198 Hash string `json:"hash"` // Full commit hash
199 Subject string `json:"subject"` // Commit subject line
200 Body string `json:"body"` // Full commit message body
201 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
202}
203
204// ToolCall represents a single tool call within an agent message
205type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700206 Name string `json:"name"`
207 Input string `json:"input"`
208 ToolCallId string `json:"tool_call_id"`
209 ResultMessage *AgentMessage `json:"result_message,omitempty"`
210 Args string `json:"args,omitempty"`
211 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700212}
213
214func (a *AgentMessage) Attr() slog.Attr {
215 var attrs []any = []any{
216 slog.String("type", string(a.Type)),
217 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700218 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700219 if a.EndOfTurn {
220 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
221 }
222 if a.Content != "" {
223 attrs = append(attrs, slog.String("content", a.Content))
224 }
225 if a.ToolName != "" {
226 attrs = append(attrs, slog.String("tool_name", a.ToolName))
227 }
228 if a.ToolInput != "" {
229 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
230 }
231 if a.Elapsed != nil {
232 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
233 }
234 if a.TurnDuration != nil {
235 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
236 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700237 if len(a.ToolResult) > 0 {
238 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700239 }
240 if a.ToolError {
241 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
242 }
243 if len(a.ToolCalls) > 0 {
244 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
245 for i, tc := range a.ToolCalls {
246 toolCallAttrs = append(toolCallAttrs, slog.Group(
247 fmt.Sprintf("tool_call_%d", i),
248 slog.String("name", tc.Name),
249 slog.String("input", tc.Input),
250 ))
251 }
252 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
253 }
254 if a.ConversationID != "" {
255 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
256 }
257 if a.ParentConversationID != nil {
258 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
259 }
260 if a.Usage != nil && !a.Usage.IsZero() {
261 attrs = append(attrs, a.Usage.Attr())
262 }
263 // TODO: timestamp, convo ids, idx?
264 return slog.Group("agent_message", attrs...)
265}
266
267func errorMessage(err error) AgentMessage {
268 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
269 if os.Getenv(("DEBUG")) == "1" {
270 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
271 }
272
273 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
274}
275
276func budgetMessage(err error) AgentMessage {
277 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
278}
279
280// ConvoInterface defines the interface for conversation interactions
281type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700282 CumulativeUsage() conversation.CumulativeUsage
283 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700284 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700285 SendMessage(message llm.Message) (*llm.Response, error)
286 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700287 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000288 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700289 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700290 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700291 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700292}
293
Philip Zeyligerf2872992025-05-22 10:35:28 -0700294// AgentGitState holds the state necessary for pushing to a remote git repo
295// when HEAD changes. If gitRemoteAddr is set, then we push to sketch/
296// any time we notice we need to.
297type AgentGitState struct {
298 mu sync.Mutex // protects following
299 lastHEAD string // hash of the last HEAD that was pushed to the host
300 gitRemoteAddr string // HTTP URL of the host git repo
301 seenCommits map[string]bool // Track git commits we've already seen (by hash)
302 branchName string
303}
304
305func (ags *AgentGitState) SetBranchName(branchName string) {
306 ags.mu.Lock()
307 defer ags.mu.Unlock()
308 ags.branchName = branchName
309}
310
311func (ags *AgentGitState) BranchName() string {
312 ags.mu.Lock()
313 defer ags.mu.Unlock()
314 return ags.branchName
315}
316
Earl Lee2e463fb2025-04-17 11:22:22 -0700317type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700318 convo ConvoInterface
319 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700320 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700321 workingDir string
322 repoRoot string // workingDir may be a subdir of repoRoot
323 url string
324 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000325 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700326 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000327 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700328 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700329 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700330 title string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000331 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700332 // State machine to track agent state
333 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000334 // Outside information
335 outsideHostname string
336 outsideOS string
337 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000338 // URL of the git remote 'origin' if it exists
339 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700340
341 // Time when the current turn started (reset at the beginning of InnerLoop)
342 startOfTurn time.Time
343
344 // Inbox - for messages from the user to the agent.
345 // sent on by UserMessage
346 // . e.g. when user types into the chat textarea
347 // read from by GatherMessages
348 inbox chan string
349
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000350 // protects cancelTurn
351 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700352 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000353 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700354
355 // protects following
356 mu sync.Mutex
357
358 // Stores all messages for this agent
359 history []AgentMessage
360
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700361 // Iterators add themselves here when they're ready to be notified of new messages.
362 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700363
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000364 // Track outstanding LLM call IDs
365 outstandingLLMCalls map[string]struct{}
366
367 // Track outstanding tool calls by ID with their names
368 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700369}
370
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700371// NewIterator implements CodingAgent.
372func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
373 a.mu.Lock()
374 defer a.mu.Unlock()
375
376 return &MessageIteratorImpl{
377 agent: a,
378 ctx: ctx,
379 nextMessageIdx: nextMessageIdx,
380 ch: make(chan *AgentMessage, 100),
381 }
382}
383
384type MessageIteratorImpl struct {
385 agent *Agent
386 ctx context.Context
387 nextMessageIdx int
388 ch chan *AgentMessage
389 subscribed bool
390}
391
392func (m *MessageIteratorImpl) Close() {
393 m.agent.mu.Lock()
394 defer m.agent.mu.Unlock()
395 // Delete ourselves from the subscribers list
396 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
397 return x == m.ch
398 })
399 close(m.ch)
400}
401
402func (m *MessageIteratorImpl) Next() *AgentMessage {
403 // We avoid subscription at creation to let ourselves catch up to "current state"
404 // before subscribing.
405 if !m.subscribed {
406 m.agent.mu.Lock()
407 if m.nextMessageIdx < len(m.agent.history) {
408 msg := &m.agent.history[m.nextMessageIdx]
409 m.nextMessageIdx++
410 m.agent.mu.Unlock()
411 return msg
412 }
413 // The next message doesn't exist yet, so let's subscribe
414 m.agent.subscribers = append(m.agent.subscribers, m.ch)
415 m.subscribed = true
416 m.agent.mu.Unlock()
417 }
418
419 for {
420 select {
421 case <-m.ctx.Done():
422 m.agent.mu.Lock()
423 // Delete ourselves from the subscribers list
424 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
425 return x == m.ch
426 })
427 m.subscribed = false
428 m.agent.mu.Unlock()
429 return nil
430 case msg, ok := <-m.ch:
431 if !ok {
432 // Close may have been called
433 return nil
434 }
435 if msg.Idx == m.nextMessageIdx {
436 m.nextMessageIdx++
437 return msg
438 }
439 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
440 panic("out of order message")
441 }
442 }
443}
444
Sean McCulloughd9d45812025-04-30 16:53:41 -0700445// Assert that Agent satisfies the CodingAgent interface.
446var _ CodingAgent = &Agent{}
447
448// StateName implements CodingAgent.
449func (a *Agent) CurrentStateName() string {
450 if a.stateMachine == nil {
451 return ""
452 }
453 return a.stateMachine.currentState.String()
454}
455
Earl Lee2e463fb2025-04-17 11:22:22 -0700456func (a *Agent) URL() string { return a.url }
457
458// Title returns the current title of the conversation.
459// If no title has been set, returns an empty string.
460func (a *Agent) Title() string {
461 a.mu.Lock()
462 defer a.mu.Unlock()
463 return a.title
464}
465
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000466// BranchName returns the git branch name for the conversation.
467func (a *Agent) BranchName() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700468 return a.gitState.BranchName()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000469}
470
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000471// OutstandingLLMCallCount returns the number of outstanding LLM calls.
472func (a *Agent) OutstandingLLMCallCount() int {
473 a.mu.Lock()
474 defer a.mu.Unlock()
475 return len(a.outstandingLLMCalls)
476}
477
478// OutstandingToolCalls returns the names of outstanding tool calls.
479func (a *Agent) OutstandingToolCalls() []string {
480 a.mu.Lock()
481 defer a.mu.Unlock()
482
483 tools := make([]string, 0, len(a.outstandingToolCalls))
484 for _, toolName := range a.outstandingToolCalls {
485 tools = append(tools, toolName)
486 }
487 return tools
488}
489
Earl Lee2e463fb2025-04-17 11:22:22 -0700490// OS returns the operating system of the client.
491func (a *Agent) OS() string {
492 return a.config.ClientGOOS
493}
494
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000495func (a *Agent) SessionID() string {
496 return a.config.SessionID
497}
498
Philip Zeyliger18532b22025-04-23 21:11:46 +0000499// OutsideOS returns the operating system of the outside system.
500func (a *Agent) OutsideOS() string {
501 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000502}
503
Philip Zeyliger18532b22025-04-23 21:11:46 +0000504// OutsideHostname returns the hostname of the outside system.
505func (a *Agent) OutsideHostname() string {
506 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000507}
508
Philip Zeyliger18532b22025-04-23 21:11:46 +0000509// OutsideWorkingDir returns the working directory on the outside system.
510func (a *Agent) OutsideWorkingDir() string {
511 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000512}
513
514// GitOrigin returns the URL of the git remote 'origin' if it exists.
515func (a *Agent) GitOrigin() string {
516 return a.gitOrigin
517}
518
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000519func (a *Agent) OpenBrowser(url string) {
520 if !a.IsInContainer() {
521 browser.Open(url)
522 return
523 }
524 // We're in Docker, need to send a request to the Git server
525 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700526 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000527 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700528 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000529 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700530 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000531 return
532 }
533 defer resp.Body.Close()
534 if resp.StatusCode == http.StatusOK {
535 return
536 }
537 body, _ := io.ReadAll(resp.Body)
538 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
539}
540
Sean McCullough96b60dd2025-04-30 09:49:10 -0700541// CurrentState returns the current state of the agent's state machine.
542func (a *Agent) CurrentState() State {
543 return a.stateMachine.CurrentState()
544}
545
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700546func (a *Agent) IsInContainer() bool {
547 return a.config.InDocker
548}
549
550func (a *Agent) FirstMessageIndex() int {
551 a.mu.Lock()
552 defer a.mu.Unlock()
553 return a.firstMessageIndex
554}
555
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000556// SetTitle sets the title of the conversation.
557func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700558 a.mu.Lock()
559 defer a.mu.Unlock()
560 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000561}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700562
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000563// SetBranch sets the branch name of the conversation.
564func (a *Agent) SetBranch(branchName string) {
565 a.mu.Lock()
566 defer a.mu.Unlock()
Philip Zeyligerf2872992025-05-22 10:35:28 -0700567 a.gitState.SetBranchName(branchName)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000568 convo, ok := a.convo.(*conversation.Convo)
569 if ok {
570 convo.ExtraData["branch"] = branchName
571 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700572}
573
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000574// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700575func (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 +0000576 // Track the tool call
577 a.mu.Lock()
578 a.outstandingToolCalls[id] = toolName
579 a.mu.Unlock()
580}
581
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700582// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
583// If there's only one element in the array and it's a text type, it returns that text directly.
584// It also processes nested ToolResult arrays recursively.
585func contentToString(contents []llm.Content) string {
586 if len(contents) == 0 {
587 return ""
588 }
589
590 // If there's only one element and it's a text type, return it directly
591 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
592 return contents[0].Text
593 }
594
595 // Otherwise, concatenate all text content
596 var result strings.Builder
597 for _, content := range contents {
598 if content.Type == llm.ContentTypeText {
599 result.WriteString(content.Text)
600 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
601 // Recursively process nested tool results
602 result.WriteString(contentToString(content.ToolResult))
603 }
604 }
605
606 return result.String()
607}
608
Earl Lee2e463fb2025-04-17 11:22:22 -0700609// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700610func (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 +0000611 // Remove the tool call from outstanding calls
612 a.mu.Lock()
613 delete(a.outstandingToolCalls, toolID)
614 a.mu.Unlock()
615
Earl Lee2e463fb2025-04-17 11:22:22 -0700616 m := AgentMessage{
617 Type: ToolUseMessageType,
618 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700619 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700620 ToolError: content.ToolError,
621 ToolName: toolName,
622 ToolInput: string(toolInput),
623 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700624 StartTime: content.ToolUseStartTime,
625 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700626 }
627
628 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700629 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
630 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700631 m.Elapsed = &elapsed
632 }
633
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700634 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700635 a.pushToOutbox(ctx, m)
636}
637
638// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700639func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000640 a.mu.Lock()
641 defer a.mu.Unlock()
642 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700643 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
644}
645
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700646// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700647// that need to be displayed (as well as tool calls that we send along when
648// they're done). (It would be reasonable to also mention tool calls when they're
649// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700650func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000651 // Remove the LLM call from outstanding calls
652 a.mu.Lock()
653 delete(a.outstandingLLMCalls, id)
654 a.mu.Unlock()
655
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700656 if resp == nil {
657 // LLM API call failed
658 m := AgentMessage{
659 Type: ErrorMessageType,
660 Content: "API call failed, type 'continue' to try again",
661 }
662 m.SetConvo(convo)
663 a.pushToOutbox(ctx, m)
664 return
665 }
666
Earl Lee2e463fb2025-04-17 11:22:22 -0700667 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700668 if convo.Parent == nil { // subconvos never end the turn
669 switch resp.StopReason {
670 case llm.StopReasonToolUse:
671 // Check whether any of the tool calls are for tools that should end the turn
672 ToolSearch:
673 for _, part := range resp.Content {
674 if part.Type != llm.ContentTypeToolUse {
675 continue
676 }
Sean McCullough021557a2025-05-05 23:20:53 +0000677 // Find the tool by name
678 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700679 if tool.Name == part.ToolName {
680 endOfTurn = tool.EndsTurn
681 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000682 }
683 }
Sean McCullough021557a2025-05-05 23:20:53 +0000684 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700685 default:
686 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000687 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700688 }
689 m := AgentMessage{
690 Type: AgentMessageType,
691 Content: collectTextContent(resp),
692 EndOfTurn: endOfTurn,
693 Usage: &resp.Usage,
694 StartTime: resp.StartTime,
695 EndTime: resp.EndTime,
696 }
697
698 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700699 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700700 var toolCalls []ToolCall
701 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700702 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700703 toolCalls = append(toolCalls, ToolCall{
704 Name: part.ToolName,
705 Input: string(part.ToolInput),
706 ToolCallId: part.ID,
707 })
708 }
709 }
710 m.ToolCalls = toolCalls
711 }
712
713 // Calculate the elapsed time if both start and end times are set
714 if resp.StartTime != nil && resp.EndTime != nil {
715 elapsed := resp.EndTime.Sub(*resp.StartTime)
716 m.Elapsed = &elapsed
717 }
718
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700719 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700720 a.pushToOutbox(ctx, m)
721}
722
723// WorkingDir implements CodingAgent.
724func (a *Agent) WorkingDir() string {
725 return a.workingDir
726}
727
728// MessageCount implements CodingAgent.
729func (a *Agent) MessageCount() int {
730 a.mu.Lock()
731 defer a.mu.Unlock()
732 return len(a.history)
733}
734
735// Messages implements CodingAgent.
736func (a *Agent) Messages(start int, end int) []AgentMessage {
737 a.mu.Lock()
738 defer a.mu.Unlock()
739 return slices.Clone(a.history[start:end])
740}
741
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700742func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700743 return a.originalBudget
744}
745
746// AgentConfig contains configuration for creating a new Agent.
747type AgentConfig struct {
748 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700749 Service llm.Service
750 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700751 GitUsername string
752 GitEmail string
753 SessionID string
754 ClientGOOS string
755 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700756 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700757 UseAnthropicEdit bool
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000758 OneShot bool
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700759 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000760 // Outside information
761 OutsideHostname string
762 OutsideOS string
763 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700764
765 // Outtie's HTTP to, e.g., open a browser
766 OutsideHTTP string
767 // Outtie's Git server
768 GitRemoteAddr string
769 // Commit to checkout from Outtie
770 Commit string
Earl Lee2e463fb2025-04-17 11:22:22 -0700771}
772
773// NewAgent creates a new Agent.
774// It is not usable until Init() is called.
775func NewAgent(config AgentConfig) *Agent {
776 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -0700777 config: config,
778 ready: make(chan struct{}),
779 inbox: make(chan string, 100),
780 subscribers: make([]chan *AgentMessage, 0),
781 startedAt: time.Now(),
782 originalBudget: config.Budget,
783 gitState: AgentGitState{
784 seenCommits: make(map[string]bool),
785 gitRemoteAddr: config.GitRemoteAddr,
786 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000787 outsideHostname: config.OutsideHostname,
788 outsideOS: config.OutsideOS,
789 outsideWorkingDir: config.OutsideWorkingDir,
790 outstandingLLMCalls: make(map[string]struct{}),
791 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700792 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700793 workingDir: config.WorkingDir,
794 outsideHTTP: config.OutsideHTTP,
Earl Lee2e463fb2025-04-17 11:22:22 -0700795 }
796 return agent
797}
798
799type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700800 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -0700801
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700802 InDocker bool
803 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -0700804}
805
806func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700807 if a.convo != nil {
808 return fmt.Errorf("Agent.Init: already initialized")
809 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700810 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -0700811 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700812
Philip Zeyligerf2872992025-05-22 10:35:28 -0700813 // If a remote git addr was specified, we configure the remote
814 if a.gitState.gitRemoteAddr != "" {
815 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
816 cmd := exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", a.gitState.gitRemoteAddr)
817 cmd.Dir = a.workingDir
818 if out, err := cmd.CombinedOutput(); err != nil {
819 return fmt.Errorf("git remote add: %s: %v", out, err)
820 }
821 // sketch-host is a git repo hosted by "outtie sketch". When it notices a 'git fetch',
822 // it runs "git fetch" underneath the covers to get its latest commits. By configuring
823 // an additional remote.sketch-host.fetch, we make "origin/main" on innie sketch look like
824 // origin/main on outtie sketch, which should make it easier to rebase.
825 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.sketch-host.fetch",
826 "+refs/heads/feature/*:refs/remotes/origin/feature/*")
827 cmd.Dir = a.workingDir
828 if out, err := cmd.CombinedOutput(); err != nil {
829 return fmt.Errorf("git config --add: %s: %v", out, err)
830 }
831 }
832
833 // If a commit was specified, we fetch and reset to it.
834 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -0700835 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
836
Earl Lee2e463fb2025-04-17 11:22:22 -0700837 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700838 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -0700839 if out, err := cmd.CombinedOutput(); err != nil {
840 return fmt.Errorf("git stash: %s: %v", out, err)
841 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000842 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700843 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -0700844 if out, err := cmd.CombinedOutput(); err != nil {
845 return fmt.Errorf("git fetch: %s: %w", out, err)
846 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700847 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
848 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100849 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
850 // Remove git hooks if they exist and retry
851 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700852 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +0100853 if _, statErr := os.Stat(hookPath); statErr == nil {
854 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
855 slog.String("error", err.Error()),
856 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700857 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +0100858 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
859 }
860
861 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700862 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
863 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100864 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700865 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 +0100866 }
867 } else {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700868 return fmt.Errorf("git checkout %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +0100869 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700870 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700871 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700872
873 if ini.HostAddr != "" {
874 a.url = "http://" + ini.HostAddr
875 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700876
877 if !ini.NoGit {
878 repoRoot, err := repoRoot(ctx, a.workingDir)
879 if err != nil {
880 return fmt.Errorf("repoRoot: %w", err)
881 }
882 a.repoRoot = repoRoot
883
Earl Lee2e463fb2025-04-17 11:22:22 -0700884 if err != nil {
885 return fmt.Errorf("resolveRef: %w", err)
886 }
Philip Zeyliger49edc922025-05-14 09:45:45 -0700887
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700888 if err := setupGitHooks(a.workingDir); err != nil {
889 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
890 }
891
Philip Zeyliger49edc922025-05-14 09:45:45 -0700892 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
893 cmd.Dir = repoRoot
894 if out, err := cmd.CombinedOutput(); err != nil {
895 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
896 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700897
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +0000898 slog.Info("running codebase analysis")
899 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
900 if err != nil {
901 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000902 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +0000903 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000904
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +0000905 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -0700906 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000907 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700908 }
909 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000910
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700911 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700912 }
Philip Zeyligerf2872992025-05-22 10:35:28 -0700913 a.gitState.lastHEAD = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -0700914 a.convo = a.initConvo()
915 close(a.ready)
916 return nil
917}
918
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700919//go:embed agent_system_prompt.txt
920var agentSystemPrompt string
921
Earl Lee2e463fb2025-04-17 11:22:22 -0700922// initConvo initializes the conversation.
923// It must not be called until all agent fields are initialized,
924// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700925func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700926 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700927 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700928 convo.PromptCaching = true
929 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000930 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000931 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -0700932
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000933 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
934 bashPermissionCheck := func(command string) error {
935 // Check if branch name is set
936 a.mu.Lock()
Philip Zeyligerf2872992025-05-22 10:35:28 -0700937 branchSet := a.gitState.BranchName() != ""
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000938 a.mu.Unlock()
939
940 // If branch is set, all commands are allowed
941 if branchSet {
942 return nil
943 }
944
945 // If branch is not set, check if this is a git commit command
946 willCommit, err := bashkit.WillRunGitCommit(command)
947 if err != nil {
948 // If there's an error checking, we should allow the command to proceed
949 return nil
950 }
951
952 // If it's a git commit and branch is not set, return an error
953 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000954 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000955 }
956
957 return nil
958 }
959
960 // Create a custom bash tool with the permission check
961 bashTool := claudetool.NewBashTool(bashPermissionCheck)
962
Earl Lee2e463fb2025-04-17 11:22:22 -0700963 // Register all tools with the conversation
964 // When adding, removing, or modifying tools here, double-check that the termui tool display
965 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000966
967 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -0700968 _, supportsScreenshots := a.config.Service.(*ant.Service)
969 var bTools []*llm.Tool
970 var browserCleanup func()
971
972 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
973 // Add cleanup function to context cancel
974 go func() {
975 <-a.config.Context.Done()
976 browserCleanup()
977 }()
978 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000979
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700980 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000981 bashTool, claudetool.Keyword,
Josh Bleecher Snyder93202652025-05-08 02:05:57 +0000982 claudetool.Think, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -0700983 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000984 }
985
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000986 // One-shot mode is non-interactive, multiple choice requires human response
987 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -0700988 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -0700989 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000990
991 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -0700992 if a.config.UseAnthropicEdit {
993 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
994 } else {
995 convo.Tools = append(convo.Tools, claudetool.Patch)
996 }
997 convo.Listener = a
998 return convo
999}
1000
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001001var multipleChoiceTool = &llm.Tool{
1002 Name: "multiplechoice",
1003 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.",
1004 EndsTurn: true,
1005 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001006 "type": "object",
1007 "description": "The question and a list of answers you would expect the user to choose from.",
1008 "properties": {
1009 "question": {
1010 "type": "string",
1011 "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?'"
1012 },
1013 "responseOptions": {
1014 "type": "array",
1015 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1016 "items": {
1017 "type": "object",
1018 "properties": {
1019 "caption": {
1020 "type": "string",
1021 "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'"
1022 },
1023 "responseText": {
1024 "type": "string",
1025 "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'"
1026 }
1027 },
1028 "required": ["caption", "responseText"]
1029 }
1030 }
1031 },
1032 "required": ["question", "responseOptions"]
1033}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001034 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1035 // The Run logic for "multiplechoice" tool is a no-op on the server.
1036 // The UI will present a list of options for the user to select from,
1037 // and that's it as far as "executing" the tool_use goes.
1038 // When the user *does* select one of the presented options, that
1039 // responseText gets sent as a chat message on behalf of the user.
1040 return llm.TextContent("end your turn and wait for the user to respond"), nil
1041 },
Sean McCullough485afc62025-04-28 14:28:39 -07001042}
1043
1044type MultipleChoiceOption struct {
1045 Caption string `json:"caption"`
1046 ResponseText string `json:"responseText"`
1047}
1048
1049type MultipleChoiceParams struct {
1050 Question string `json:"question"`
1051 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1052}
1053
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001054// branchExists reports whether branchName exists, either locally or in well-known remotes.
1055func branchExists(dir, branchName string) bool {
1056 refs := []string{
1057 "refs/heads/",
1058 "refs/remotes/origin/",
1059 "refs/remotes/sketch-host/",
1060 }
1061 for _, ref := range refs {
1062 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1063 cmd.Dir = dir
1064 if cmd.Run() == nil { // exit code 0 means branch exists
1065 return true
1066 }
1067 }
1068 return false
1069}
1070
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001071func (a *Agent) titleTool() *llm.Tool {
1072 description := `Sets the conversation title.`
1073 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001074 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001075 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001076 InputSchema: json.RawMessage(`{
1077 "type": "object",
1078 "properties": {
1079 "title": {
1080 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001081 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001082 }
1083 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001084 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001085}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001086 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001087 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001088 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001089 }
1090 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001091 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001092 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001093
1094 // We don't allow changing the title once set to be consistent with the previous behavior
1095 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001096 t := a.Title()
1097 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001098 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001099 }
1100
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001101 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001102 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001103 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001104
1105 a.SetTitle(params.Title)
1106 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001107 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001108 },
1109 }
1110 return titleTool
1111}
1112
1113func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001114 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 +00001115 preCommit := &llm.Tool{
1116 Name: "precommit",
1117 Description: description,
1118 InputSchema: json.RawMessage(`{
1119 "type": "object",
1120 "properties": {
1121 "branch_name": {
1122 "type": "string",
1123 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1124 }
1125 },
1126 "required": ["branch_name"]
1127}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001128 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001129 var params struct {
1130 BranchName string `json:"branch_name"`
1131 }
1132 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001133 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001134 }
1135
1136 b := a.BranchName()
1137 if b != "" {
Josh Bleecher Snyder44d1f1a2025-05-12 19:18:32 -07001138 return nil, fmt.Errorf("branch already set to %s; do not create a new branch", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001139 }
1140
1141 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001142 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001143 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001144 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001145 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001146 }
1147 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001148 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001149 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001150 }
1151
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001152 a.SetBranch(branchName)
Josh Bleecher Snyderf7bebdd2025-05-14 15:22:24 -07001153 response := fmt.Sprintf("switched to branch sketch/%q - DO NOT change branches unless explicitly requested", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001154
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001155 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1156 if err != nil {
1157 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1158 }
1159 if len(styleHint) > 0 {
1160 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001161 }
1162
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001163 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001164 },
1165 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001166 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001167}
1168
1169func (a *Agent) Ready() <-chan struct{} {
1170 return a.ready
1171}
1172
1173func (a *Agent) UserMessage(ctx context.Context, msg string) {
1174 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1175 a.inbox <- msg
1176}
1177
Earl Lee2e463fb2025-04-17 11:22:22 -07001178func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1179 return a.convo.CancelToolUse(toolUseID, cause)
1180}
1181
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001182func (a *Agent) CancelTurn(cause error) {
1183 a.cancelTurnMu.Lock()
1184 defer a.cancelTurnMu.Unlock()
1185 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001186 // Force state transition to cancelled state
1187 ctx := a.config.Context
1188 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001189 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001190 }
1191}
1192
1193func (a *Agent) Loop(ctxOuter context.Context) {
1194 for {
1195 select {
1196 case <-ctxOuter.Done():
1197 return
1198 default:
1199 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001200 a.cancelTurnMu.Lock()
1201 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001202 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001203 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001204 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001205 a.cancelTurn = cancel
1206 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001207 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1208 if err != nil {
1209 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1210 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001211 cancel(nil)
1212 }
1213 }
1214}
1215
1216func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1217 if m.Timestamp.IsZero() {
1218 m.Timestamp = time.Now()
1219 }
1220
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001221 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1222 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1223 m.Content = m.ToolResult
1224 }
1225
Earl Lee2e463fb2025-04-17 11:22:22 -07001226 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1227 if m.EndOfTurn && m.Type == AgentMessageType {
1228 turnDuration := time.Since(a.startOfTurn)
1229 m.TurnDuration = &turnDuration
1230 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1231 }
1232
Earl Lee2e463fb2025-04-17 11:22:22 -07001233 a.mu.Lock()
1234 defer a.mu.Unlock()
1235 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001236 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001237 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001238
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001239 // Notify all subscribers
1240 for _, ch := range a.subscribers {
1241 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001242 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001243}
1244
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001245func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1246 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001247 if block {
1248 select {
1249 case <-ctx.Done():
1250 return m, ctx.Err()
1251 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001252 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001253 }
1254 }
1255 for {
1256 select {
1257 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001258 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001259 default:
1260 return m, nil
1261 }
1262 }
1263}
1264
Sean McCullough885a16a2025-04-30 02:49:25 +00001265// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001266func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001267 // Reset the start of turn time
1268 a.startOfTurn = time.Now()
1269
Sean McCullough96b60dd2025-04-30 09:49:10 -07001270 // Transition to waiting for user input state
1271 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1272
Sean McCullough885a16a2025-04-30 02:49:25 +00001273 // Process initial user message
1274 initialResp, err := a.processUserMessage(ctx)
1275 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001276 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001277 return err
1278 }
1279
1280 // Handle edge case where both initialResp and err are nil
1281 if initialResp == nil {
1282 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001283 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1284
Sean McCullough9f4b8082025-04-30 17:34:07 +00001285 a.pushToOutbox(ctx, errorMessage(err))
1286 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001287 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001288
Earl Lee2e463fb2025-04-17 11:22:22 -07001289 // We do this as we go, but let's also do it at the end of the turn
1290 defer func() {
1291 if _, err := a.handleGitCommits(ctx); err != nil {
1292 // Just log the error, don't stop execution
1293 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1294 }
1295 }()
1296
Sean McCullougha1e0e492025-05-01 10:51:08 -07001297 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001298 resp := initialResp
1299 for {
1300 // Check if we are over budget
1301 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001302 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001303 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001304 }
1305
1306 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001307 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001308 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001309 break
1310 }
1311
Sean McCullough96b60dd2025-04-30 09:49:10 -07001312 // Transition to tool use requested state
1313 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1314
Sean McCullough885a16a2025-04-30 02:49:25 +00001315 // Handle tool execution
1316 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1317 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001318 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001319 }
1320
Sean McCullougha1e0e492025-05-01 10:51:08 -07001321 if toolResp == nil {
1322 return fmt.Errorf("cannot continue conversation with a nil tool response")
1323 }
1324
Sean McCullough885a16a2025-04-30 02:49:25 +00001325 // Set the response for the next iteration
1326 resp = toolResp
1327 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001328
1329 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001330}
1331
1332// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001333func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001334 // Wait for at least one message from the user
1335 msgs, err := a.GatherMessages(ctx, true)
1336 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001337 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001338 return nil, err
1339 }
1340
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001341 userMessage := llm.Message{
1342 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001343 Content: msgs,
1344 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001345
Sean McCullough96b60dd2025-04-30 09:49:10 -07001346 // Transition to sending to LLM state
1347 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1348
Sean McCullough885a16a2025-04-30 02:49:25 +00001349 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001350 resp, err := a.convo.SendMessage(userMessage)
1351 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001352 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001353 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001354 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001355 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001356
Sean McCullough96b60dd2025-04-30 09:49:10 -07001357 // Transition to processing LLM response state
1358 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1359
Sean McCullough885a16a2025-04-30 02:49:25 +00001360 return resp, nil
1361}
1362
1363// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001364func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1365 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001366 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001367 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001368
Sean McCullough96b60dd2025-04-30 09:49:10 -07001369 // Transition to checking for cancellation state
1370 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1371
Sean McCullough885a16a2025-04-30 02:49:25 +00001372 // Check if the operation was cancelled by the user
1373 select {
1374 case <-ctx.Done():
1375 // Don't actually run any of the tools, but rather build a response
1376 // for each tool_use message letting the LLM know that user canceled it.
1377 var err error
1378 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001379 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001380 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001381 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001382 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001383 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001384 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001385 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001386 // Transition to running tool state
1387 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1388
Sean McCullough885a16a2025-04-30 02:49:25 +00001389 // Add working directory to context for tool execution
1390 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1391
1392 // Execute the tools
1393 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001394 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001395 if ctx.Err() != nil { // e.g. the user canceled the operation
1396 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001397 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001398 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001399 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001400 a.pushToOutbox(ctx, errorMessage(err))
1401 }
1402 }
1403
1404 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001405 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001406 autoqualityMessages := a.processGitChanges(ctx)
1407
1408 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001409 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001410 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001411 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001412 return false, nil
1413 }
1414
1415 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001416 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1417 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001418}
1419
1420// processGitChanges checks for new git commits and runs autoformatters if needed
1421func (a *Agent) processGitChanges(ctx context.Context) []string {
1422 // Check for git commits after tool execution
1423 newCommits, err := a.handleGitCommits(ctx)
1424 if err != nil {
1425 // Just log the error, don't stop execution
1426 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1427 return nil
1428 }
1429
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001430 // Run mechanical checks if there was exactly one new commit.
1431 if len(newCommits) != 1 {
1432 return nil
1433 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001434 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001435 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1436 msg := a.codereview.RunMechanicalChecks(ctx)
1437 if msg != "" {
1438 a.pushToOutbox(ctx, AgentMessage{
1439 Type: AutoMessageType,
1440 Content: msg,
1441 Timestamp: time.Now(),
1442 })
1443 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001444 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001445
1446 return autoqualityMessages
1447}
1448
1449// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001450func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001451 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001452 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001453 msgs, err := a.GatherMessages(ctx, false)
1454 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001455 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001456 return false, nil
1457 }
1458
1459 // Inject any auto-generated messages from quality checks
1460 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001461 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001462 }
1463
1464 // Handle cancellation by appending a message about it
1465 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001466 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001467 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001468 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001469 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1470 } else if err := a.convo.OverBudget(); err != nil {
1471 // Handle budget issues by appending a message about it
1472 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 -07001473 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001474 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1475 }
1476
1477 // Combine tool results with user messages
1478 results = append(results, msgs...)
1479
1480 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001481 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001482 resp, err := a.convo.SendMessage(llm.Message{
1483 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001484 Content: results,
1485 })
1486 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001487 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001488 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1489 return true, nil // Return true to continue the conversation, but with no response
1490 }
1491
Sean McCullough96b60dd2025-04-30 09:49:10 -07001492 // Transition back to processing LLM response
1493 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1494
Sean McCullough885a16a2025-04-30 02:49:25 +00001495 if cancelled {
1496 return false, nil
1497 }
1498
1499 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001500}
1501
1502func (a *Agent) overBudget(ctx context.Context) error {
1503 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001504 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001505 m := budgetMessage(err)
1506 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001507 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001508 a.convo.ResetBudget(a.originalBudget)
1509 return err
1510 }
1511 return nil
1512}
1513
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001514func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001515 // Collect all text content
1516 var allText strings.Builder
1517 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001518 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001519 if allText.Len() > 0 {
1520 allText.WriteString("\n\n")
1521 }
1522 allText.WriteString(content.Text)
1523 }
1524 }
1525 return allText.String()
1526}
1527
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001528func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001529 a.mu.Lock()
1530 defer a.mu.Unlock()
1531 return a.convo.CumulativeUsage()
1532}
1533
Earl Lee2e463fb2025-04-17 11:22:22 -07001534// Diff returns a unified diff of changes made since the agent was instantiated.
1535func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001536 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001537 return "", fmt.Errorf("no initial commit reference available")
1538 }
1539
1540 // Find the repository root
1541 ctx := context.Background()
1542
1543 // If a specific commit hash is provided, show just that commit's changes
1544 if commit != nil && *commit != "" {
1545 // Validate that the commit looks like a valid git SHA
1546 if !isValidGitSHA(*commit) {
1547 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1548 }
1549
1550 // Get the diff for just this commit
1551 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1552 cmd.Dir = a.repoRoot
1553 output, err := cmd.CombinedOutput()
1554 if err != nil {
1555 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1556 }
1557 return string(output), nil
1558 }
1559
1560 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001561 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001562 cmd.Dir = a.repoRoot
1563 output, err := cmd.CombinedOutput()
1564 if err != nil {
1565 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1566 }
1567
1568 return string(output), nil
1569}
1570
Philip Zeyliger49edc922025-05-14 09:45:45 -07001571// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1572// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1573func (a *Agent) SketchGitBaseRef() string {
1574 if a.IsInContainer() {
1575 return "sketch-base"
1576 } else {
1577 return "sketch-base-" + a.SessionID()
1578 }
1579}
1580
1581// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1582func (a *Agent) SketchGitBase() string {
1583 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1584 cmd.Dir = a.repoRoot
1585 output, err := cmd.CombinedOutput()
1586 if err != nil {
1587 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1588 return "HEAD"
1589 }
1590 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001591}
1592
Pokey Rule7a113622025-05-12 10:58:45 +01001593// removeGitHooks removes the Git hooks directory from the repository
1594func removeGitHooks(_ context.Context, repoPath string) error {
1595 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1596
1597 // Check if hooks directory exists
1598 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1599 // Directory doesn't exist, nothing to do
1600 return nil
1601 }
1602
1603 // Remove the hooks directory
1604 err := os.RemoveAll(hooksDir)
1605 if err != nil {
1606 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1607 }
1608
1609 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001610 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001611 if err != nil {
1612 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1613 }
1614
1615 return nil
1616}
1617
Philip Zeyligerf2872992025-05-22 10:35:28 -07001618func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1619 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef())
1620 for _, msg := range msgs {
1621 a.pushToOutbox(ctx, msg)
1622 }
1623 return commits, error
1624}
1625
Earl Lee2e463fb2025-04-17 11:22:22 -07001626// handleGitCommits() highlights new commits to the user. When running
1627// under docker, new HEADs are pushed to a branch according to the title.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001628func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string) ([]AgentMessage, []*GitCommit, error) {
1629 ags.mu.Lock()
1630 defer ags.mu.Unlock()
1631
1632 msgs := []AgentMessage{}
1633 if repoRoot == "" {
1634 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001635 }
1636
Philip Zeyligerf2872992025-05-22 10:35:28 -07001637 head, err := resolveRef(ctx, repoRoot, "HEAD")
Earl Lee2e463fb2025-04-17 11:22:22 -07001638 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001639 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001640 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001641 if head == ags.lastHEAD {
1642 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001643 }
1644 defer func() {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001645 ags.lastHEAD = head
Earl Lee2e463fb2025-04-17 11:22:22 -07001646 }()
1647
1648 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1649 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1650 // to the last 100 commits.
1651 var commits []*GitCommit
1652
1653 // Get commits since the initial commit
1654 // Format: <hash>\0<subject>\0<body>\0
1655 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1656 // Limit to 100 commits to avoid overwhelming the user
Philip Zeyligerf2872992025-05-22 10:35:28 -07001657 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, head)
1658 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001659 output, err := cmd.Output()
1660 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001661 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001662 }
1663
1664 // Parse git log output and filter out already seen commits
1665 parsedCommits := parseGitLog(string(output))
1666
1667 var headCommit *GitCommit
1668
1669 // Filter out commits we've already seen
1670 for _, commit := range parsedCommits {
1671 if commit.Hash == head {
1672 headCommit = &commit
1673 }
1674
1675 // Skip if we've seen this commit before. If our head has changed, always include that.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001676 if ags.seenCommits[commit.Hash] && commit.Hash != head {
Earl Lee2e463fb2025-04-17 11:22:22 -07001677 continue
1678 }
1679
1680 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001681 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001682
1683 // Add to our list of new commits
1684 commits = append(commits, &commit)
1685 }
1686
Philip Zeyligerf2872992025-05-22 10:35:28 -07001687 if ags.gitRemoteAddr != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001688 if headCommit == nil {
1689 // I think this can only happen if we have a bug or if there's a race.
1690 headCommit = &GitCommit{}
1691 headCommit.Hash = head
1692 headCommit.Subject = "unknown"
1693 commits = append(commits, headCommit)
1694 }
1695
Philip Zeyligerf2872992025-05-22 10:35:28 -07001696 originalBranch := cmp.Or(ags.branchName, "sketch/"+sessionID)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001697 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001698
1699 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1700 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1701 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001702
1703 // Try up to 10 times with different branch names if the branch is checked out on the remote
1704 var out []byte
1705 var err error
1706 for retries := range 10 {
1707 if retries > 0 {
1708 // Add a numeric suffix to the branch name
1709 branch = fmt.Sprintf("%s%d", originalBranch, retries)
1710 }
1711
Philip Zeyligerf2872992025-05-22 10:35:28 -07001712 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1713 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001714 out, err = cmd.CombinedOutput()
1715
1716 if err == nil {
1717 // Success! Break out of the retry loop
1718 break
1719 }
1720
1721 // Check if this is the "refusing to update checked out branch" error
1722 if !strings.Contains(string(out), "refusing to update checked out branch") {
1723 // This is a different error, so don't retry
1724 break
1725 }
1726
1727 // If we're on the last retry, we'll report the error
1728 if retries == 9 {
1729 break
1730 }
1731 }
1732
1733 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001734 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001735 } else {
1736 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001737 // Update the agent's branch name if we ended up using a different one
1738 if branch != originalBranch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001739 ags.branchName = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001740 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001741 }
1742 }
1743
1744 // If we found new commits, create a message
1745 if len(commits) > 0 {
1746 msg := AgentMessage{
1747 Type: CommitMessageType,
1748 Timestamp: time.Now(),
1749 Commits: commits,
1750 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001751 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001752 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001753 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001754}
1755
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001756func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001757 return strings.Map(func(r rune) rune {
1758 // lowercase
1759 if r >= 'A' && r <= 'Z' {
1760 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001761 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001762 // replace spaces with dashes
1763 if r == ' ' {
1764 return '-'
1765 }
1766 // allow alphanumerics and dashes
1767 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1768 return r
1769 }
1770 return -1
1771 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001772}
1773
1774// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1775// and returns an array of GitCommit structs.
1776func parseGitLog(output string) []GitCommit {
1777 var commits []GitCommit
1778
1779 // No output means no commits
1780 if len(output) == 0 {
1781 return commits
1782 }
1783
1784 // Split by NULL byte
1785 parts := strings.Split(output, "\x00")
1786
1787 // Process in triplets (hash, subject, body)
1788 for i := 0; i < len(parts); i++ {
1789 // Skip empty parts
1790 if parts[i] == "" {
1791 continue
1792 }
1793
1794 // This should be a hash
1795 hash := strings.TrimSpace(parts[i])
1796
1797 // Make sure we have at least a subject part available
1798 if i+1 >= len(parts) {
1799 break // No more parts available
1800 }
1801
1802 // Get the subject
1803 subject := strings.TrimSpace(parts[i+1])
1804
1805 // Get the body if available
1806 body := ""
1807 if i+2 < len(parts) {
1808 body = strings.TrimSpace(parts[i+2])
1809 }
1810
1811 // Skip to the next triplet
1812 i += 2
1813
1814 commits = append(commits, GitCommit{
1815 Hash: hash,
1816 Subject: subject,
1817 Body: body,
1818 })
1819 }
1820
1821 return commits
1822}
1823
1824func repoRoot(ctx context.Context, dir string) (string, error) {
1825 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1826 stderr := new(strings.Builder)
1827 cmd.Stderr = stderr
1828 cmd.Dir = dir
1829 out, err := cmd.Output()
1830 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001831 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07001832 }
1833 return strings.TrimSpace(string(out)), nil
1834}
1835
1836func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1837 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1838 stderr := new(strings.Builder)
1839 cmd.Stderr = stderr
1840 cmd.Dir = dir
1841 out, err := cmd.Output()
1842 if err != nil {
1843 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1844 }
1845 // TODO: validate that out is valid hex
1846 return strings.TrimSpace(string(out)), nil
1847}
1848
1849// isValidGitSHA validates if a string looks like a valid git SHA hash.
1850// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1851func isValidGitSHA(sha string) bool {
1852 // Git SHA must be a hexadecimal string with at least 4 characters
1853 if len(sha) < 4 || len(sha) > 40 {
1854 return false
1855 }
1856
1857 // Check if the string only contains hexadecimal characters
1858 for _, char := range sha {
1859 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1860 return false
1861 }
1862 }
1863
1864 return true
1865}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001866
1867// getGitOrigin returns the URL of the git remote 'origin' if it exists
1868func getGitOrigin(ctx context.Context, dir string) string {
1869 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1870 cmd.Dir = dir
1871 stderr := new(strings.Builder)
1872 cmd.Stderr = stderr
1873 out, err := cmd.Output()
1874 if err != nil {
1875 return ""
1876 }
1877 return strings.TrimSpace(string(out))
1878}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001879
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001880// systemPromptData contains the data used to render the system prompt template
1881type systemPromptData struct {
1882 EditPrompt string
1883 ClientGOOS string
1884 ClientGOARCH string
1885 WorkingDir string
1886 RepoRoot string
1887 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001888 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001889}
1890
1891// renderSystemPrompt renders the system prompt template.
1892func (a *Agent) renderSystemPrompt() string {
1893 // Determine the appropriate edit prompt based on config
1894 var editPrompt string
1895 if a.config.UseAnthropicEdit {
1896 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."
1897 } else {
1898 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1899 }
1900
1901 data := systemPromptData{
1902 EditPrompt: editPrompt,
1903 ClientGOOS: a.config.ClientGOOS,
1904 ClientGOARCH: a.config.ClientGOARCH,
1905 WorkingDir: a.workingDir,
1906 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07001907 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001908 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001909 }
1910
1911 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1912 if err != nil {
1913 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1914 }
1915 buf := new(strings.Builder)
1916 err = tmpl.Execute(buf, data)
1917 if err != nil {
1918 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1919 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001920 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001921 return buf.String()
1922}
Philip Zeyligereab12de2025-05-14 02:35:53 +00001923
1924// StateTransitionIterator provides an iterator over state transitions.
1925type StateTransitionIterator interface {
1926 // Next blocks until a new state transition is available or context is done.
1927 // Returns nil if the context is cancelled.
1928 Next() *StateTransition
1929 // Close removes the listener and cleans up resources.
1930 Close()
1931}
1932
1933// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
1934type StateTransitionIteratorImpl struct {
1935 agent *Agent
1936 ctx context.Context
1937 ch chan StateTransition
1938 unsubscribe func()
1939}
1940
1941// Next blocks until a new state transition is available or the context is cancelled.
1942func (s *StateTransitionIteratorImpl) Next() *StateTransition {
1943 select {
1944 case <-s.ctx.Done():
1945 return nil
1946 case transition, ok := <-s.ch:
1947 if !ok {
1948 return nil
1949 }
1950 transitionCopy := transition
1951 return &transitionCopy
1952 }
1953}
1954
1955// Close removes the listener and cleans up resources.
1956func (s *StateTransitionIteratorImpl) Close() {
1957 if s.unsubscribe != nil {
1958 s.unsubscribe()
1959 s.unsubscribe = nil
1960 }
1961}
1962
1963// NewStateTransitionIterator returns an iterator that receives state transitions.
1964func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
1965 a.mu.Lock()
1966 defer a.mu.Unlock()
1967
1968 // Create channel to receive state transitions
1969 ch := make(chan StateTransition, 10)
1970
1971 // Add a listener to the state machine
1972 unsubscribe := a.stateMachine.AddTransitionListener(ch)
1973
1974 return &StateTransitionIteratorImpl{
1975 agent: a,
1976 ctx: ctx,
1977 ch: ch,
1978 unsubscribe: unsubscribe,
1979 }
1980}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00001981
1982// setupGitHooks creates or updates git hooks in the specified working directory.
1983func setupGitHooks(workingDir string) error {
1984 hooksDir := filepath.Join(workingDir, ".git", "hooks")
1985
1986 _, err := os.Stat(hooksDir)
1987 if os.IsNotExist(err) {
1988 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
1989 }
1990 if err != nil {
1991 return fmt.Errorf("error checking git hooks directory: %w", err)
1992 }
1993
1994 // Define the post-commit hook content
1995 postCommitHook := `#!/bin/bash
1996echo "<post_commit_hook>"
1997echo "Please review this commit message and fix it if it is incorrect."
1998echo "This hook only echos the commit message; it does not modify it."
1999echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2000echo "<last_commit_message>"
2001git log -1 --pretty=%B
2002echo "</last_commit_message>"
2003echo "</post_commit_hook>"
2004`
2005
2006 // Define the prepare-commit-msg hook content
2007 prepareCommitMsgHook := `#!/bin/bash
2008# Add Co-Authored-By and Change-ID trailers to commit messages
2009# Check if these trailers already exist before adding them
2010
2011commit_file="$1"
2012COMMIT_SOURCE="$2"
2013
2014# Skip for merges, squashes, or when using a commit template
2015if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2016 [ "$COMMIT_SOURCE" = "squash" ]; then
2017 exit 0
2018fi
2019
2020commit_msg=$(cat "$commit_file")
2021
2022needs_co_author=true
2023needs_change_id=true
2024
2025# Check if commit message already has Co-Authored-By trailer
2026if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2027 needs_co_author=false
2028fi
2029
2030# Check if commit message already has Change-ID trailer
2031if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2032 needs_change_id=false
2033fi
2034
2035# Only modify if at least one trailer needs to be added
2036if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
2037 # Ensure there's a blank line before trailers
2038 if [ -s "$commit_file" ] && [ "$(tail -1 "$commit_file" | tr -d '\n')" != "" ]; then
2039 echo "" >> "$commit_file"
2040 fi
2041
2042 # Add trailers if needed
2043 if [ "$needs_co_author" = true ]; then
2044 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2045 fi
2046
2047 if [ "$needs_change_id" = true ]; then
2048 change_id=$(openssl rand -hex 8)
2049 echo "Change-ID: s${change_id}k" >> "$commit_file"
2050 fi
2051fi
2052`
2053
2054 // Update or create the post-commit hook
2055 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2056 if err != nil {
2057 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2058 }
2059
2060 // Update or create the prepare-commit-msg hook
2061 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2062 if err != nil {
2063 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2064 }
2065
2066 return nil
2067}
2068
2069// updateOrCreateHook creates a new hook file or updates an existing one
2070// by appending the new content if it doesn't already contain it.
2071func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2072 // Check if the hook already exists
2073 buf, err := os.ReadFile(hookPath)
2074 if os.IsNotExist(err) {
2075 // Hook doesn't exist, create it
2076 err = os.WriteFile(hookPath, []byte(content), 0o755)
2077 if err != nil {
2078 return fmt.Errorf("failed to create hook: %w", err)
2079 }
2080 return nil
2081 }
2082 if err != nil {
2083 return fmt.Errorf("error reading existing hook: %w", err)
2084 }
2085
2086 // Hook exists, check if our content is already in it by looking for a distinctive line
2087 code := string(buf)
2088 if strings.Contains(code, distinctiveLine) {
2089 // Already contains our content, nothing to do
2090 return nil
2091 }
2092
2093 // Append our content to the existing hook
2094 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2095 if err != nil {
2096 return fmt.Errorf("failed to open hook for appending: %w", err)
2097 }
2098 defer f.Close()
2099
2100 // Ensure there's a newline at the end of the existing content if needed
2101 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2102 _, err = f.WriteString("\n")
2103 if err != nil {
2104 return fmt.Errorf("failed to add newline to hook: %w", err)
2105 }
2106 }
2107
2108 // Add a separator before our content
2109 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2110 if err != nil {
2111 return fmt.Errorf("failed to append to hook: %w", err)
2112 }
2113
2114 return nil
2115}