blob: 438d326d1409417f31f9cbf31f7488b4af5d6924 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package loop
2
3import (
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07004 "cmp"
Earl Lee2e463fb2025-04-17 11:22:22 -07005 "context"
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07006 _ "embed"
Earl Lee2e463fb2025-04-17 11:22:22 -07007 "encoding/json"
8 "fmt"
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +00009 "io"
Earl Lee2e463fb2025-04-17 11:22:22 -070010 "log/slog"
11 "net/http"
12 "os"
13 "os/exec"
Pokey Rule7a113622025-05-12 10:58:45 +010014 "path/filepath"
Earl Lee2e463fb2025-04-17 11:22:22 -070015 "runtime/debug"
16 "slices"
17 "strings"
18 "sync"
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +000019 "text/template"
Earl Lee2e463fb2025-04-17 11:22:22 -070020 "time"
21
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000022 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070023 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000024 "sketch.dev/claudetool/bashkit"
Autoformatter4962f152025-05-06 17:24:20 +000025 "sketch.dev/claudetool/browse"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000026 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydera997be62025-05-07 22:52:46 +000027 "sketch.dev/claudetool/onstart"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070028 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070029 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070030 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070031)
32
33const (
34 userCancelMessage = "user requested agent to stop handling responses"
35)
36
Philip Zeyligerb7c58752025-05-01 10:10:17 -070037type MessageIterator interface {
38 // Next blocks until the next message is available. It may
39 // return nil if the underlying iterator context is done.
40 Next() *AgentMessage
41 Close()
42}
43
Earl Lee2e463fb2025-04-17 11:22:22 -070044type CodingAgent interface {
45 // Init initializes an agent inside a docker container.
46 Init(AgentInit) error
47
48 // Ready returns a channel closed after Init successfully called.
49 Ready() <-chan struct{}
50
51 // URL reports the HTTP URL of this agent.
52 URL() string
53
54 // UserMessage enqueues a message to the agent and returns immediately.
55 UserMessage(ctx context.Context, msg string)
56
Philip Zeyligerb7c58752025-05-01 10:10:17 -070057 // Returns an iterator that finishes when the context is done and
58 // starts with the given message index.
59 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070060
Philip Zeyligereab12de2025-05-14 02:35:53 +000061 // Returns an iterator that notifies of state transitions until the context is done.
62 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
63
Earl Lee2e463fb2025-04-17 11:22:22 -070064 // Loop begins the agent loop returns only when ctx is cancelled.
65 Loop(ctx context.Context)
66
Sean McCulloughedc88dc2025-04-30 02:55:01 +000067 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070068
69 CancelToolUse(toolUseID string, cause error) error
70
71 // Returns a subset of the agent's message history.
72 Messages(start int, end int) []AgentMessage
73
74 // Returns the current number of messages in the history
75 MessageCount() int
76
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070077 TotalUsage() conversation.CumulativeUsage
78 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070079
Earl Lee2e463fb2025-04-17 11:22:22 -070080 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000081 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070082
83 // Diff returns a unified diff of changes made since the agent was instantiated.
84 // If commit is non-nil, it shows the diff for just that specific commit.
85 Diff(commit *string) (string, error)
86
Philip Zeyliger49edc922025-05-14 09:45:45 -070087 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
88 // starts out as the commit where sketch started, but a user can move it if need
89 // be, for example in the case of a rebase. It is stored as a git tag.
90 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070091
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000092 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
93 // (Typically, this is "sketch-base")
94 SketchGitBaseRef() string
95
Earl Lee2e463fb2025-04-17 11:22:22 -070096 // Title returns the current title of the conversation.
97 Title() string
98
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000099 // BranchName returns the git branch name for the conversation.
100 BranchName() string
101
Earl Lee2e463fb2025-04-17 11:22:22 -0700102 // OS returns the operating system of the client.
103 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000104
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000105 // SessionID returns the unique session identifier.
106 SessionID() string
107
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000108 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700109 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000110
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000111 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
112 OutstandingLLMCallCount() int
113
114 // OutstandingToolCalls returns the names of outstanding tool calls.
115 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000116 OutsideOS() string
117 OutsideHostname() string
118 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000119 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000120 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
121 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700122
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700123 // IsInContainer returns true if the agent is running in a container
124 IsInContainer() bool
125 // FirstMessageIndex returns the index of the first message in the current conversation
126 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700127
128 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700129 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
130 CurrentTodoContent() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700131}
132
133type CodingAgentMessageType string
134
135const (
136 UserMessageType CodingAgentMessageType = "user"
137 AgentMessageType CodingAgentMessageType = "agent"
138 ErrorMessageType CodingAgentMessageType = "error"
139 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
140 ToolUseMessageType CodingAgentMessageType = "tool"
141 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
142 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
143
144 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
145)
146
147type AgentMessage struct {
148 Type CodingAgentMessageType `json:"type"`
149 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
150 EndOfTurn bool `json:"end_of_turn"`
151
152 Content string `json:"content"`
153 ToolName string `json:"tool_name,omitempty"`
154 ToolInput string `json:"input,omitempty"`
155 ToolResult string `json:"tool_result,omitempty"`
156 ToolError bool `json:"tool_error,omitempty"`
157 ToolCallId string `json:"tool_call_id,omitempty"`
158
159 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
160 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
161
Sean McCulloughd9f13372025-04-21 15:08:49 -0700162 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
163 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
164
Earl Lee2e463fb2025-04-17 11:22:22 -0700165 // Commits is a list of git commits for a commit message
166 Commits []*GitCommit `json:"commits,omitempty"`
167
168 Timestamp time.Time `json:"timestamp"`
169 ConversationID string `json:"conversation_id"`
170 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700171 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700172
173 // Message timing information
174 StartTime *time.Time `json:"start_time,omitempty"`
175 EndTime *time.Time `json:"end_time,omitempty"`
176 Elapsed *time.Duration `json:"elapsed,omitempty"`
177
178 // Turn duration - the time taken for a complete agent turn
179 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
180
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000181 // HideOutput indicates that this message should not be rendered in the UI.
182 // This is useful for subconversations that generate output that shouldn't be shown to the user.
183 HideOutput bool `json:"hide_output,omitempty"`
184
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700185 // TodoContent contains the agent's todo file content when it has changed
186 TodoContent *string `json:"todo_content,omitempty"`
187
Earl Lee2e463fb2025-04-17 11:22:22 -0700188 Idx int `json:"idx"`
189}
190
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000191// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700192func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700193 if convo == nil {
194 m.ConversationID = ""
195 m.ParentConversationID = nil
196 return
197 }
198 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000199 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700200 if convo.Parent != nil {
201 m.ParentConversationID = &convo.Parent.ID
202 }
203}
204
Earl Lee2e463fb2025-04-17 11:22:22 -0700205// GitCommit represents a single git commit for a commit message
206type GitCommit struct {
207 Hash string `json:"hash"` // Full commit hash
208 Subject string `json:"subject"` // Commit subject line
209 Body string `json:"body"` // Full commit message body
210 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
211}
212
213// ToolCall represents a single tool call within an agent message
214type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700215 Name string `json:"name"`
216 Input string `json:"input"`
217 ToolCallId string `json:"tool_call_id"`
218 ResultMessage *AgentMessage `json:"result_message,omitempty"`
219 Args string `json:"args,omitempty"`
220 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700221}
222
223func (a *AgentMessage) Attr() slog.Attr {
224 var attrs []any = []any{
225 slog.String("type", string(a.Type)),
226 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700227 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700228 if a.EndOfTurn {
229 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
230 }
231 if a.Content != "" {
232 attrs = append(attrs, slog.String("content", a.Content))
233 }
234 if a.ToolName != "" {
235 attrs = append(attrs, slog.String("tool_name", a.ToolName))
236 }
237 if a.ToolInput != "" {
238 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
239 }
240 if a.Elapsed != nil {
241 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
242 }
243 if a.TurnDuration != nil {
244 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
245 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700246 if len(a.ToolResult) > 0 {
247 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700248 }
249 if a.ToolError {
250 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
251 }
252 if len(a.ToolCalls) > 0 {
253 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
254 for i, tc := range a.ToolCalls {
255 toolCallAttrs = append(toolCallAttrs, slog.Group(
256 fmt.Sprintf("tool_call_%d", i),
257 slog.String("name", tc.Name),
258 slog.String("input", tc.Input),
259 ))
260 }
261 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
262 }
263 if a.ConversationID != "" {
264 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
265 }
266 if a.ParentConversationID != nil {
267 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
268 }
269 if a.Usage != nil && !a.Usage.IsZero() {
270 attrs = append(attrs, a.Usage.Attr())
271 }
272 // TODO: timestamp, convo ids, idx?
273 return slog.Group("agent_message", attrs...)
274}
275
276func errorMessage(err error) AgentMessage {
277 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
278 if os.Getenv(("DEBUG")) == "1" {
279 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
280 }
281
282 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
283}
284
285func budgetMessage(err error) AgentMessage {
286 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
287}
288
289// ConvoInterface defines the interface for conversation interactions
290type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700291 CumulativeUsage() conversation.CumulativeUsage
292 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700293 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700294 SendMessage(message llm.Message) (*llm.Response, error)
295 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700296 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000297 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700298 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700299 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700300 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700301}
302
Philip Zeyligerf2872992025-05-22 10:35:28 -0700303// AgentGitState holds the state necessary for pushing to a remote git repo
304// when HEAD changes. If gitRemoteAddr is set, then we push to sketch/
305// any time we notice we need to.
306type AgentGitState struct {
307 mu sync.Mutex // protects following
308 lastHEAD string // hash of the last HEAD that was pushed to the host
309 gitRemoteAddr string // HTTP URL of the host git repo
310 seenCommits map[string]bool // Track git commits we've already seen (by hash)
311 branchName string
312}
313
314func (ags *AgentGitState) SetBranchName(branchName string) {
315 ags.mu.Lock()
316 defer ags.mu.Unlock()
317 ags.branchName = branchName
318}
319
320func (ags *AgentGitState) BranchName() string {
321 ags.mu.Lock()
322 defer ags.mu.Unlock()
323 return ags.branchName
324}
325
Earl Lee2e463fb2025-04-17 11:22:22 -0700326type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700327 convo ConvoInterface
328 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700329 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700330 workingDir string
331 repoRoot string // workingDir may be a subdir of repoRoot
332 url string
333 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000334 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700335 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000336 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700337 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700338 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700339 title string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000340 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700341 // State machine to track agent state
342 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000343 // Outside information
344 outsideHostname string
345 outsideOS string
346 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000347 // URL of the git remote 'origin' if it exists
348 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700349
350 // Time when the current turn started (reset at the beginning of InnerLoop)
351 startOfTurn time.Time
352
353 // Inbox - for messages from the user to the agent.
354 // sent on by UserMessage
355 // . e.g. when user types into the chat textarea
356 // read from by GatherMessages
357 inbox chan string
358
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000359 // protects cancelTurn
360 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700361 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000362 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700363
364 // protects following
365 mu sync.Mutex
366
367 // Stores all messages for this agent
368 history []AgentMessage
369
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700370 // Iterators add themselves here when they're ready to be notified of new messages.
371 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700372
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000373 // Track outstanding LLM call IDs
374 outstandingLLMCalls map[string]struct{}
375
376 // Track outstanding tool calls by ID with their names
377 outstandingToolCalls map[string]string
Sean McCullough364f7412025-06-02 00:55:44 +0000378
379 // Port monitoring
380 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700381}
382
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700383// NewIterator implements CodingAgent.
384func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
385 a.mu.Lock()
386 defer a.mu.Unlock()
387
388 return &MessageIteratorImpl{
389 agent: a,
390 ctx: ctx,
391 nextMessageIdx: nextMessageIdx,
392 ch: make(chan *AgentMessage, 100),
393 }
394}
395
396type MessageIteratorImpl struct {
397 agent *Agent
398 ctx context.Context
399 nextMessageIdx int
400 ch chan *AgentMessage
401 subscribed bool
402}
403
404func (m *MessageIteratorImpl) Close() {
405 m.agent.mu.Lock()
406 defer m.agent.mu.Unlock()
407 // Delete ourselves from the subscribers list
408 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
409 return x == m.ch
410 })
411 close(m.ch)
412}
413
414func (m *MessageIteratorImpl) Next() *AgentMessage {
415 // We avoid subscription at creation to let ourselves catch up to "current state"
416 // before subscribing.
417 if !m.subscribed {
418 m.agent.mu.Lock()
419 if m.nextMessageIdx < len(m.agent.history) {
420 msg := &m.agent.history[m.nextMessageIdx]
421 m.nextMessageIdx++
422 m.agent.mu.Unlock()
423 return msg
424 }
425 // The next message doesn't exist yet, so let's subscribe
426 m.agent.subscribers = append(m.agent.subscribers, m.ch)
427 m.subscribed = true
428 m.agent.mu.Unlock()
429 }
430
431 for {
432 select {
433 case <-m.ctx.Done():
434 m.agent.mu.Lock()
435 // Delete ourselves from the subscribers list
436 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
437 return x == m.ch
438 })
439 m.subscribed = false
440 m.agent.mu.Unlock()
441 return nil
442 case msg, ok := <-m.ch:
443 if !ok {
444 // Close may have been called
445 return nil
446 }
447 if msg.Idx == m.nextMessageIdx {
448 m.nextMessageIdx++
449 return msg
450 }
451 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
452 panic("out of order message")
453 }
454 }
455}
456
Sean McCulloughd9d45812025-04-30 16:53:41 -0700457// Assert that Agent satisfies the CodingAgent interface.
458var _ CodingAgent = &Agent{}
459
460// StateName implements CodingAgent.
461func (a *Agent) CurrentStateName() string {
462 if a.stateMachine == nil {
463 return ""
464 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000465 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700466}
467
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700468// CurrentTodoContent returns the current todo list data as JSON.
469// It returns an empty string if no todos exist.
470func (a *Agent) CurrentTodoContent() string {
471 todoPath := claudetool.TodoFilePath(a.config.SessionID)
472 content, err := os.ReadFile(todoPath)
473 if err != nil {
474 return ""
475 }
476 return string(content)
477}
478
Earl Lee2e463fb2025-04-17 11:22:22 -0700479func (a *Agent) URL() string { return a.url }
480
481// Title returns the current title of the conversation.
482// If no title has been set, returns an empty string.
483func (a *Agent) Title() string {
484 a.mu.Lock()
485 defer a.mu.Unlock()
486 return a.title
487}
488
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000489// BranchName returns the git branch name for the conversation.
490func (a *Agent) BranchName() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700491 return a.gitState.BranchName()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000492}
493
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000494// OutstandingLLMCallCount returns the number of outstanding LLM calls.
495func (a *Agent) OutstandingLLMCallCount() int {
496 a.mu.Lock()
497 defer a.mu.Unlock()
498 return len(a.outstandingLLMCalls)
499}
500
501// OutstandingToolCalls returns the names of outstanding tool calls.
502func (a *Agent) OutstandingToolCalls() []string {
503 a.mu.Lock()
504 defer a.mu.Unlock()
505
506 tools := make([]string, 0, len(a.outstandingToolCalls))
507 for _, toolName := range a.outstandingToolCalls {
508 tools = append(tools, toolName)
509 }
510 return tools
511}
512
Earl Lee2e463fb2025-04-17 11:22:22 -0700513// OS returns the operating system of the client.
514func (a *Agent) OS() string {
515 return a.config.ClientGOOS
516}
517
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000518func (a *Agent) SessionID() string {
519 return a.config.SessionID
520}
521
Philip Zeyliger18532b22025-04-23 21:11:46 +0000522// OutsideOS returns the operating system of the outside system.
523func (a *Agent) OutsideOS() string {
524 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000525}
526
Philip Zeyliger18532b22025-04-23 21:11:46 +0000527// OutsideHostname returns the hostname of the outside system.
528func (a *Agent) OutsideHostname() string {
529 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000530}
531
Philip Zeyliger18532b22025-04-23 21:11:46 +0000532// OutsideWorkingDir returns the working directory on the outside system.
533func (a *Agent) OutsideWorkingDir() string {
534 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000535}
536
537// GitOrigin returns the URL of the git remote 'origin' if it exists.
538func (a *Agent) GitOrigin() string {
539 return a.gitOrigin
540}
541
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000542func (a *Agent) OpenBrowser(url string) {
543 if !a.IsInContainer() {
544 browser.Open(url)
545 return
546 }
547 // We're in Docker, need to send a request to the Git server
548 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700549 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000550 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700551 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000552 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700553 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000554 return
555 }
556 defer resp.Body.Close()
557 if resp.StatusCode == http.StatusOK {
558 return
559 }
560 body, _ := io.ReadAll(resp.Body)
561 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
562}
563
Sean McCullough96b60dd2025-04-30 09:49:10 -0700564// CurrentState returns the current state of the agent's state machine.
565func (a *Agent) CurrentState() State {
566 return a.stateMachine.CurrentState()
567}
568
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700569func (a *Agent) IsInContainer() bool {
570 return a.config.InDocker
571}
572
573func (a *Agent) FirstMessageIndex() int {
574 a.mu.Lock()
575 defer a.mu.Unlock()
576 return a.firstMessageIndex
577}
578
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000579// SetTitle sets the title of the conversation.
580func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700581 a.mu.Lock()
582 defer a.mu.Unlock()
583 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000584}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700585
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000586// SetBranch sets the branch name of the conversation.
587func (a *Agent) SetBranch(branchName string) {
588 a.mu.Lock()
589 defer a.mu.Unlock()
Philip Zeyligerf2872992025-05-22 10:35:28 -0700590 a.gitState.SetBranchName(branchName)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000591 convo, ok := a.convo.(*conversation.Convo)
592 if ok {
593 convo.ExtraData["branch"] = branchName
594 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700595}
596
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000597// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700598func (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 +0000599 // Track the tool call
600 a.mu.Lock()
601 a.outstandingToolCalls[id] = toolName
602 a.mu.Unlock()
603}
604
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700605// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
606// If there's only one element in the array and it's a text type, it returns that text directly.
607// It also processes nested ToolResult arrays recursively.
608func contentToString(contents []llm.Content) string {
609 if len(contents) == 0 {
610 return ""
611 }
612
613 // If there's only one element and it's a text type, return it directly
614 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
615 return contents[0].Text
616 }
617
618 // Otherwise, concatenate all text content
619 var result strings.Builder
620 for _, content := range contents {
621 if content.Type == llm.ContentTypeText {
622 result.WriteString(content.Text)
623 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
624 // Recursively process nested tool results
625 result.WriteString(contentToString(content.ToolResult))
626 }
627 }
628
629 return result.String()
630}
631
Earl Lee2e463fb2025-04-17 11:22:22 -0700632// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700633func (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 +0000634 // Remove the tool call from outstanding calls
635 a.mu.Lock()
636 delete(a.outstandingToolCalls, toolID)
637 a.mu.Unlock()
638
Earl Lee2e463fb2025-04-17 11:22:22 -0700639 m := AgentMessage{
640 Type: ToolUseMessageType,
641 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700642 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700643 ToolError: content.ToolError,
644 ToolName: toolName,
645 ToolInput: string(toolInput),
646 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700647 StartTime: content.ToolUseStartTime,
648 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700649 }
650
651 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700652 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
653 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700654 m.Elapsed = &elapsed
655 }
656
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700657 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700658 a.pushToOutbox(ctx, m)
659}
660
661// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700662func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000663 a.mu.Lock()
664 defer a.mu.Unlock()
665 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700666 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
667}
668
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700669// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700670// that need to be displayed (as well as tool calls that we send along when
671// they're done). (It would be reasonable to also mention tool calls when they're
672// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700673func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000674 // Remove the LLM call from outstanding calls
675 a.mu.Lock()
676 delete(a.outstandingLLMCalls, id)
677 a.mu.Unlock()
678
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700679 if resp == nil {
680 // LLM API call failed
681 m := AgentMessage{
682 Type: ErrorMessageType,
683 Content: "API call failed, type 'continue' to try again",
684 }
685 m.SetConvo(convo)
686 a.pushToOutbox(ctx, m)
687 return
688 }
689
Earl Lee2e463fb2025-04-17 11:22:22 -0700690 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700691 if convo.Parent == nil { // subconvos never end the turn
692 switch resp.StopReason {
693 case llm.StopReasonToolUse:
694 // Check whether any of the tool calls are for tools that should end the turn
695 ToolSearch:
696 for _, part := range resp.Content {
697 if part.Type != llm.ContentTypeToolUse {
698 continue
699 }
Sean McCullough021557a2025-05-05 23:20:53 +0000700 // Find the tool by name
701 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700702 if tool.Name == part.ToolName {
703 endOfTurn = tool.EndsTurn
704 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000705 }
706 }
Sean McCullough021557a2025-05-05 23:20:53 +0000707 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700708 default:
709 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000710 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700711 }
712 m := AgentMessage{
713 Type: AgentMessageType,
714 Content: collectTextContent(resp),
715 EndOfTurn: endOfTurn,
716 Usage: &resp.Usage,
717 StartTime: resp.StartTime,
718 EndTime: resp.EndTime,
719 }
720
721 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700722 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700723 var toolCalls []ToolCall
724 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700725 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700726 toolCalls = append(toolCalls, ToolCall{
727 Name: part.ToolName,
728 Input: string(part.ToolInput),
729 ToolCallId: part.ID,
730 })
731 }
732 }
733 m.ToolCalls = toolCalls
734 }
735
736 // Calculate the elapsed time if both start and end times are set
737 if resp.StartTime != nil && resp.EndTime != nil {
738 elapsed := resp.EndTime.Sub(*resp.StartTime)
739 m.Elapsed = &elapsed
740 }
741
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700742 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700743 a.pushToOutbox(ctx, m)
744}
745
746// WorkingDir implements CodingAgent.
747func (a *Agent) WorkingDir() string {
748 return a.workingDir
749}
750
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000751// RepoRoot returns the git repository root directory.
752func (a *Agent) RepoRoot() string {
753 return a.repoRoot
754}
755
Earl Lee2e463fb2025-04-17 11:22:22 -0700756// MessageCount implements CodingAgent.
757func (a *Agent) MessageCount() int {
758 a.mu.Lock()
759 defer a.mu.Unlock()
760 return len(a.history)
761}
762
763// Messages implements CodingAgent.
764func (a *Agent) Messages(start int, end int) []AgentMessage {
765 a.mu.Lock()
766 defer a.mu.Unlock()
767 return slices.Clone(a.history[start:end])
768}
769
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700770func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700771 return a.originalBudget
772}
773
774// AgentConfig contains configuration for creating a new Agent.
775type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +0000776 Context context.Context
777 Service llm.Service
778 Budget conversation.Budget
779 GitUsername string
780 GitEmail string
781 SessionID string
782 ClientGOOS string
783 ClientGOARCH string
784 InDocker bool
785 OneShot bool
786 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000787 // Outside information
788 OutsideHostname string
789 OutsideOS string
790 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700791
792 // Outtie's HTTP to, e.g., open a browser
793 OutsideHTTP string
794 // Outtie's Git server
795 GitRemoteAddr string
796 // Commit to checkout from Outtie
797 Commit string
Earl Lee2e463fb2025-04-17 11:22:22 -0700798}
799
800// NewAgent creates a new Agent.
801// It is not usable until Init() is called.
802func NewAgent(config AgentConfig) *Agent {
803 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -0700804 config: config,
805 ready: make(chan struct{}),
806 inbox: make(chan string, 100),
807 subscribers: make([]chan *AgentMessage, 0),
808 startedAt: time.Now(),
809 originalBudget: config.Budget,
810 gitState: AgentGitState{
811 seenCommits: make(map[string]bool),
812 gitRemoteAddr: config.GitRemoteAddr,
813 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000814 outsideHostname: config.OutsideHostname,
815 outsideOS: config.OutsideOS,
816 outsideWorkingDir: config.OutsideWorkingDir,
817 outstandingLLMCalls: make(map[string]struct{}),
818 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700819 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700820 workingDir: config.WorkingDir,
821 outsideHTTP: config.OutsideHTTP,
Sean McCullough364f7412025-06-02 00:55:44 +0000822 portMonitor: NewPortMonitor(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700823 }
824 return agent
825}
826
827type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700828 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -0700829
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700830 InDocker bool
831 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -0700832}
833
834func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700835 if a.convo != nil {
836 return fmt.Errorf("Agent.Init: already initialized")
837 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700838 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -0700839 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700840
Philip Zeyligerf2872992025-05-22 10:35:28 -0700841 // If a remote git addr was specified, we configure the remote
842 if a.gitState.gitRemoteAddr != "" {
843 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
844 cmd := exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", a.gitState.gitRemoteAddr)
845 cmd.Dir = a.workingDir
846 if out, err := cmd.CombinedOutput(); err != nil {
847 return fmt.Errorf("git remote add: %s: %v", out, err)
848 }
849 // sketch-host is a git repo hosted by "outtie sketch". When it notices a 'git fetch',
850 // it runs "git fetch" underneath the covers to get its latest commits. By configuring
851 // an additional remote.sketch-host.fetch, we make "origin/main" on innie sketch look like
852 // origin/main on outtie sketch, which should make it easier to rebase.
853 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.sketch-host.fetch",
854 "+refs/heads/feature/*:refs/remotes/origin/feature/*")
855 cmd.Dir = a.workingDir
856 if out, err := cmd.CombinedOutput(); err != nil {
857 return fmt.Errorf("git config --add: %s: %v", out, err)
858 }
859 }
860
861 // If a commit was specified, we fetch and reset to it.
862 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -0700863 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
864
Earl Lee2e463fb2025-04-17 11:22:22 -0700865 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700866 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -0700867 if out, err := cmd.CombinedOutput(); err != nil {
868 return fmt.Errorf("git stash: %s: %v", out, err)
869 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000870 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700871 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -0700872 if out, err := cmd.CombinedOutput(); err != nil {
873 return fmt.Errorf("git fetch: %s: %w", out, err)
874 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700875 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
876 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100877 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
878 // Remove git hooks if they exist and retry
879 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700880 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +0100881 if _, statErr := os.Stat(hookPath); statErr == nil {
882 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
883 slog.String("error", err.Error()),
884 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700885 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +0100886 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
887 }
888
889 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700890 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
891 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100892 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700893 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 +0100894 }
895 } else {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700896 return fmt.Errorf("git checkout %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +0100897 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700898 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700899 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700900
901 if ini.HostAddr != "" {
902 a.url = "http://" + ini.HostAddr
903 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700904
905 if !ini.NoGit {
906 repoRoot, err := repoRoot(ctx, a.workingDir)
907 if err != nil {
908 return fmt.Errorf("repoRoot: %w", err)
909 }
910 a.repoRoot = repoRoot
911
Earl Lee2e463fb2025-04-17 11:22:22 -0700912 if err != nil {
913 return fmt.Errorf("resolveRef: %w", err)
914 }
Philip Zeyliger49edc922025-05-14 09:45:45 -0700915
Josh Bleecher Snyder90993a02025-05-28 18:15:15 -0700916 if err := setupGitHooks(a.repoRoot); err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700917 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
918 }
919
Philip Zeyliger49edc922025-05-14 09:45:45 -0700920 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
921 cmd.Dir = repoRoot
922 if out, err := cmd.CombinedOutput(); err != nil {
923 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
924 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700925
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +0000926 slog.Info("running codebase analysis")
927 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
928 if err != nil {
929 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000930 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +0000931 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000932
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +0000933 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -0700934 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000935 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700936 }
937 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000938
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700939 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700940 }
Philip Zeyligerf2872992025-05-22 10:35:28 -0700941 a.gitState.lastHEAD = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -0700942 a.convo = a.initConvo()
943 close(a.ready)
944 return nil
945}
946
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700947//go:embed agent_system_prompt.txt
948var agentSystemPrompt string
949
Earl Lee2e463fb2025-04-17 11:22:22 -0700950// initConvo initializes the conversation.
951// It must not be called until all agent fields are initialized,
952// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700953func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700954 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700955 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700956 convo.PromptCaching = true
957 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000958 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000959 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -0700960
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000961 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
962 bashPermissionCheck := func(command string) error {
963 // Check if branch name is set
964 a.mu.Lock()
Philip Zeyligerf2872992025-05-22 10:35:28 -0700965 branchSet := a.gitState.BranchName() != ""
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000966 a.mu.Unlock()
967
968 // If branch is set, all commands are allowed
969 if branchSet {
970 return nil
971 }
972
973 // If branch is not set, check if this is a git commit command
974 willCommit, err := bashkit.WillRunGitCommit(command)
975 if err != nil {
976 // If there's an error checking, we should allow the command to proceed
977 return nil
978 }
979
980 // If it's a git commit and branch is not set, return an error
981 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000982 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000983 }
984
985 return nil
986 }
987
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +0000988 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000989
Earl Lee2e463fb2025-04-17 11:22:22 -0700990 // Register all tools with the conversation
991 // When adding, removing, or modifying tools here, double-check that the termui tool display
992 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000993
994 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -0700995 _, supportsScreenshots := a.config.Service.(*ant.Service)
996 var bTools []*llm.Tool
997 var browserCleanup func()
998
999 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1000 // Add cleanup function to context cancel
1001 go func() {
1002 <-a.config.Context.Done()
1003 browserCleanup()
1004 }()
1005 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001006
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001007 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001008 bashTool, claudetool.Keyword, claudetool.Patch,
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001009 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001010 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001011 }
1012
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001013 // One-shot mode is non-interactive, multiple choice requires human response
1014 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001015 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001016 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001017
1018 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -07001019 convo.Listener = a
1020 return convo
1021}
1022
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001023var multipleChoiceTool = &llm.Tool{
1024 Name: "multiplechoice",
1025 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.",
1026 EndsTurn: true,
1027 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001028 "type": "object",
1029 "description": "The question and a list of answers you would expect the user to choose from.",
1030 "properties": {
1031 "question": {
1032 "type": "string",
1033 "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?'"
1034 },
1035 "responseOptions": {
1036 "type": "array",
1037 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1038 "items": {
1039 "type": "object",
1040 "properties": {
1041 "caption": {
1042 "type": "string",
1043 "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'"
1044 },
1045 "responseText": {
1046 "type": "string",
1047 "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'"
1048 }
1049 },
1050 "required": ["caption", "responseText"]
1051 }
1052 }
1053 },
1054 "required": ["question", "responseOptions"]
1055}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001056 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1057 // The Run logic for "multiplechoice" tool is a no-op on the server.
1058 // The UI will present a list of options for the user to select from,
1059 // and that's it as far as "executing" the tool_use goes.
1060 // When the user *does* select one of the presented options, that
1061 // responseText gets sent as a chat message on behalf of the user.
1062 return llm.TextContent("end your turn and wait for the user to respond"), nil
1063 },
Sean McCullough485afc62025-04-28 14:28:39 -07001064}
1065
1066type MultipleChoiceOption struct {
1067 Caption string `json:"caption"`
1068 ResponseText string `json:"responseText"`
1069}
1070
1071type MultipleChoiceParams struct {
1072 Question string `json:"question"`
1073 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1074}
1075
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001076// branchExists reports whether branchName exists, either locally or in well-known remotes.
1077func branchExists(dir, branchName string) bool {
1078 refs := []string{
1079 "refs/heads/",
1080 "refs/remotes/origin/",
1081 "refs/remotes/sketch-host/",
1082 }
1083 for _, ref := range refs {
1084 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1085 cmd.Dir = dir
1086 if cmd.Run() == nil { // exit code 0 means branch exists
1087 return true
1088 }
1089 }
1090 return false
1091}
1092
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001093func (a *Agent) titleTool() *llm.Tool {
1094 description := `Sets the conversation title.`
1095 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001096 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001097 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001098 InputSchema: json.RawMessage(`{
1099 "type": "object",
1100 "properties": {
1101 "title": {
1102 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001103 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001104 }
1105 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001106 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001107}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001108 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001109 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001110 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001111 }
1112 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001113 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001114 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001115
1116 // We don't allow changing the title once set to be consistent with the previous behavior
1117 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001118 t := a.Title()
1119 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001120 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001121 }
1122
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001123 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001124 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001125 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001126
1127 a.SetTitle(params.Title)
1128 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001129 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001130 },
1131 }
1132 return titleTool
1133}
1134
1135func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001136 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 +00001137 preCommit := &llm.Tool{
1138 Name: "precommit",
1139 Description: description,
1140 InputSchema: json.RawMessage(`{
1141 "type": "object",
1142 "properties": {
1143 "branch_name": {
1144 "type": "string",
1145 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1146 }
1147 },
1148 "required": ["branch_name"]
1149}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001150 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001151 var params struct {
1152 BranchName string `json:"branch_name"`
1153 }
1154 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001155 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001156 }
1157
1158 b := a.BranchName()
1159 if b != "" {
Josh Bleecher Snyder44d1f1a2025-05-12 19:18:32 -07001160 return nil, fmt.Errorf("branch already set to %s; do not create a new branch", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001161 }
1162
1163 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001164 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001165 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001166 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001167 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001168 }
1169 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001170 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001171 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001172 }
1173
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001174 a.SetBranch(branchName)
Josh Bleecher Snyderf7bebdd2025-05-14 15:22:24 -07001175 response := fmt.Sprintf("switched to branch sketch/%q - DO NOT change branches unless explicitly requested", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001176
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001177 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1178 if err != nil {
1179 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1180 }
1181 if len(styleHint) > 0 {
1182 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001183 }
1184
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001185 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001186 },
1187 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001188 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001189}
1190
1191func (a *Agent) Ready() <-chan struct{} {
1192 return a.ready
1193}
1194
1195func (a *Agent) UserMessage(ctx context.Context, msg string) {
1196 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1197 a.inbox <- msg
1198}
1199
Earl Lee2e463fb2025-04-17 11:22:22 -07001200func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1201 return a.convo.CancelToolUse(toolUseID, cause)
1202}
1203
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001204func (a *Agent) CancelTurn(cause error) {
1205 a.cancelTurnMu.Lock()
1206 defer a.cancelTurnMu.Unlock()
1207 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001208 // Force state transition to cancelled state
1209 ctx := a.config.Context
1210 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001211 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001212 }
1213}
1214
1215func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001216 // Start port monitoring when the agent loop begins
1217 // Only monitor ports when running in a container
1218 if a.IsInContainer() {
1219 a.portMonitor.Start(ctxOuter)
1220 }
1221
Earl Lee2e463fb2025-04-17 11:22:22 -07001222 for {
1223 select {
1224 case <-ctxOuter.Done():
1225 return
1226 default:
1227 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001228 a.cancelTurnMu.Lock()
1229 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001230 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001231 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001232 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001233 a.cancelTurn = cancel
1234 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001235 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1236 if err != nil {
1237 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1238 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001239 cancel(nil)
1240 }
1241 }
1242}
1243
1244func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1245 if m.Timestamp.IsZero() {
1246 m.Timestamp = time.Now()
1247 }
1248
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001249 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1250 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1251 m.Content = m.ToolResult
1252 }
1253
Earl Lee2e463fb2025-04-17 11:22:22 -07001254 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1255 if m.EndOfTurn && m.Type == AgentMessageType {
1256 turnDuration := time.Since(a.startOfTurn)
1257 m.TurnDuration = &turnDuration
1258 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1259 }
1260
Earl Lee2e463fb2025-04-17 11:22:22 -07001261 a.mu.Lock()
1262 defer a.mu.Unlock()
1263 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001264 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001265 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001266
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001267 // Notify all subscribers
1268 for _, ch := range a.subscribers {
1269 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001270 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001271}
1272
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001273func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1274 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001275 if block {
1276 select {
1277 case <-ctx.Done():
1278 return m, ctx.Err()
1279 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001280 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001281 }
1282 }
1283 for {
1284 select {
1285 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001286 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001287 default:
1288 return m, nil
1289 }
1290 }
1291}
1292
Sean McCullough885a16a2025-04-30 02:49:25 +00001293// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001294func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001295 // Reset the start of turn time
1296 a.startOfTurn = time.Now()
1297
Sean McCullough96b60dd2025-04-30 09:49:10 -07001298 // Transition to waiting for user input state
1299 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1300
Sean McCullough885a16a2025-04-30 02:49:25 +00001301 // Process initial user message
1302 initialResp, err := a.processUserMessage(ctx)
1303 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001304 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001305 return err
1306 }
1307
1308 // Handle edge case where both initialResp and err are nil
1309 if initialResp == nil {
1310 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001311 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1312
Sean McCullough9f4b8082025-04-30 17:34:07 +00001313 a.pushToOutbox(ctx, errorMessage(err))
1314 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001315 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001316
Earl Lee2e463fb2025-04-17 11:22:22 -07001317 // We do this as we go, but let's also do it at the end of the turn
1318 defer func() {
1319 if _, err := a.handleGitCommits(ctx); err != nil {
1320 // Just log the error, don't stop execution
1321 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1322 }
1323 }()
1324
Sean McCullougha1e0e492025-05-01 10:51:08 -07001325 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001326 resp := initialResp
1327 for {
1328 // Check if we are over budget
1329 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001330 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001331 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001332 }
1333
1334 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001335 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001336 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001337 break
1338 }
1339
Sean McCullough96b60dd2025-04-30 09:49:10 -07001340 // Transition to tool use requested state
1341 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1342
Sean McCullough885a16a2025-04-30 02:49:25 +00001343 // Handle tool execution
1344 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1345 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001346 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001347 }
1348
Sean McCullougha1e0e492025-05-01 10:51:08 -07001349 if toolResp == nil {
1350 return fmt.Errorf("cannot continue conversation with a nil tool response")
1351 }
1352
Sean McCullough885a16a2025-04-30 02:49:25 +00001353 // Set the response for the next iteration
1354 resp = toolResp
1355 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001356
1357 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001358}
1359
1360// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001361func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001362 // Wait for at least one message from the user
1363 msgs, err := a.GatherMessages(ctx, true)
1364 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001365 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001366 return nil, err
1367 }
1368
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001369 userMessage := llm.Message{
1370 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001371 Content: msgs,
1372 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001373
Sean McCullough96b60dd2025-04-30 09:49:10 -07001374 // Transition to sending to LLM state
1375 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1376
Sean McCullough885a16a2025-04-30 02:49:25 +00001377 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001378 resp, err := a.convo.SendMessage(userMessage)
1379 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001380 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001381 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001382 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001383 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001384
Sean McCullough96b60dd2025-04-30 09:49:10 -07001385 // Transition to processing LLM response state
1386 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1387
Sean McCullough885a16a2025-04-30 02:49:25 +00001388 return resp, nil
1389}
1390
1391// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001392func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1393 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001394 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001395 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001396
Sean McCullough96b60dd2025-04-30 09:49:10 -07001397 // Transition to checking for cancellation state
1398 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1399
Sean McCullough885a16a2025-04-30 02:49:25 +00001400 // Check if the operation was cancelled by the user
1401 select {
1402 case <-ctx.Done():
1403 // Don't actually run any of the tools, but rather build a response
1404 // for each tool_use message letting the LLM know that user canceled it.
1405 var err error
1406 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001407 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001408 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001409 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001410 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001411 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001412 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001413 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001414 // Transition to running tool state
1415 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1416
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001417 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001418 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001419 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001420
1421 // Execute the tools
1422 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001423 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001424 if ctx.Err() != nil { // e.g. the user canceled the operation
1425 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001426 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001427 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001428 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001429 a.pushToOutbox(ctx, errorMessage(err))
1430 }
1431 }
1432
1433 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001434 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001435 autoqualityMessages := a.processGitChanges(ctx)
1436
1437 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001438 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001439 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001440 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001441 return false, nil
1442 }
1443
1444 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001445 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1446 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001447}
1448
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001449// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001450func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001451 // Check for git commits
1452 _, err := a.handleGitCommits(ctx)
1453 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001454 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001455 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001456 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001457 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001458}
1459
1460// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1461// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001462func (a *Agent) processGitChanges(ctx context.Context) []string {
1463 // Check for git commits after tool execution
1464 newCommits, err := a.handleGitCommits(ctx)
1465 if err != nil {
1466 // Just log the error, don't stop execution
1467 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1468 return nil
1469 }
1470
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001471 // Run mechanical checks if there was exactly one new commit.
1472 if len(newCommits) != 1 {
1473 return nil
1474 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001475 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001476 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1477 msg := a.codereview.RunMechanicalChecks(ctx)
1478 if msg != "" {
1479 a.pushToOutbox(ctx, AgentMessage{
1480 Type: AutoMessageType,
1481 Content: msg,
1482 Timestamp: time.Now(),
1483 })
1484 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001485 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001486
1487 return autoqualityMessages
1488}
1489
1490// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001491func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001492 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001493 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001494 msgs, err := a.GatherMessages(ctx, false)
1495 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001496 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001497 return false, nil
1498 }
1499
1500 // Inject any auto-generated messages from quality checks
1501 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001502 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001503 }
1504
1505 // Handle cancellation by appending a message about it
1506 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001507 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001508 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001509 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001510 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1511 } else if err := a.convo.OverBudget(); err != nil {
1512 // Handle budget issues by appending a message about it
1513 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 -07001514 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001515 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1516 }
1517
1518 // Combine tool results with user messages
1519 results = append(results, msgs...)
1520
1521 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001522 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001523 resp, err := a.convo.SendMessage(llm.Message{
1524 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001525 Content: results,
1526 })
1527 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001528 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001529 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1530 return true, nil // Return true to continue the conversation, but with no response
1531 }
1532
Sean McCullough96b60dd2025-04-30 09:49:10 -07001533 // Transition back to processing LLM response
1534 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1535
Sean McCullough885a16a2025-04-30 02:49:25 +00001536 if cancelled {
1537 return false, nil
1538 }
1539
1540 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001541}
1542
1543func (a *Agent) overBudget(ctx context.Context) error {
1544 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001545 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001546 m := budgetMessage(err)
1547 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001548 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001549 a.convo.ResetBudget(a.originalBudget)
1550 return err
1551 }
1552 return nil
1553}
1554
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001555func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001556 // Collect all text content
1557 var allText strings.Builder
1558 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001559 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001560 if allText.Len() > 0 {
1561 allText.WriteString("\n\n")
1562 }
1563 allText.WriteString(content.Text)
1564 }
1565 }
1566 return allText.String()
1567}
1568
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001569func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001570 a.mu.Lock()
1571 defer a.mu.Unlock()
1572 return a.convo.CumulativeUsage()
1573}
1574
Earl Lee2e463fb2025-04-17 11:22:22 -07001575// Diff returns a unified diff of changes made since the agent was instantiated.
1576func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001577 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001578 return "", fmt.Errorf("no initial commit reference available")
1579 }
1580
1581 // Find the repository root
1582 ctx := context.Background()
1583
1584 // If a specific commit hash is provided, show just that commit's changes
1585 if commit != nil && *commit != "" {
1586 // Validate that the commit looks like a valid git SHA
1587 if !isValidGitSHA(*commit) {
1588 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1589 }
1590
1591 // Get the diff for just this commit
1592 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1593 cmd.Dir = a.repoRoot
1594 output, err := cmd.CombinedOutput()
1595 if err != nil {
1596 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1597 }
1598 return string(output), nil
1599 }
1600
1601 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001602 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001603 cmd.Dir = a.repoRoot
1604 output, err := cmd.CombinedOutput()
1605 if err != nil {
1606 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1607 }
1608
1609 return string(output), nil
1610}
1611
Philip Zeyliger49edc922025-05-14 09:45:45 -07001612// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1613// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1614func (a *Agent) SketchGitBaseRef() string {
1615 if a.IsInContainer() {
1616 return "sketch-base"
1617 } else {
1618 return "sketch-base-" + a.SessionID()
1619 }
1620}
1621
1622// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1623func (a *Agent) SketchGitBase() string {
1624 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1625 cmd.Dir = a.repoRoot
1626 output, err := cmd.CombinedOutput()
1627 if err != nil {
1628 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1629 return "HEAD"
1630 }
1631 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001632}
1633
Pokey Rule7a113622025-05-12 10:58:45 +01001634// removeGitHooks removes the Git hooks directory from the repository
1635func removeGitHooks(_ context.Context, repoPath string) error {
1636 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1637
1638 // Check if hooks directory exists
1639 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1640 // Directory doesn't exist, nothing to do
1641 return nil
1642 }
1643
1644 // Remove the hooks directory
1645 err := os.RemoveAll(hooksDir)
1646 if err != nil {
1647 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1648 }
1649
1650 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001651 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001652 if err != nil {
1653 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1654 }
1655
1656 return nil
1657}
1658
Philip Zeyligerf2872992025-05-22 10:35:28 -07001659func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1660 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef())
1661 for _, msg := range msgs {
1662 a.pushToOutbox(ctx, msg)
1663 }
1664 return commits, error
1665}
1666
Earl Lee2e463fb2025-04-17 11:22:22 -07001667// handleGitCommits() highlights new commits to the user. When running
1668// under docker, new HEADs are pushed to a branch according to the title.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001669func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string) ([]AgentMessage, []*GitCommit, error) {
1670 ags.mu.Lock()
1671 defer ags.mu.Unlock()
1672
1673 msgs := []AgentMessage{}
1674 if repoRoot == "" {
1675 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001676 }
1677
Philip Zeyligerf2872992025-05-22 10:35:28 -07001678 head, err := resolveRef(ctx, repoRoot, "HEAD")
Earl Lee2e463fb2025-04-17 11:22:22 -07001679 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001680 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001681 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001682 if head == ags.lastHEAD {
1683 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001684 }
1685 defer func() {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001686 ags.lastHEAD = head
Earl Lee2e463fb2025-04-17 11:22:22 -07001687 }()
1688
1689 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1690 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1691 // to the last 100 commits.
1692 var commits []*GitCommit
1693
1694 // Get commits since the initial commit
1695 // Format: <hash>\0<subject>\0<body>\0
1696 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1697 // Limit to 100 commits to avoid overwhelming the user
Philip Zeyligerf2872992025-05-22 10:35:28 -07001698 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, head)
1699 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001700 output, err := cmd.Output()
1701 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001702 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001703 }
1704
1705 // Parse git log output and filter out already seen commits
1706 parsedCommits := parseGitLog(string(output))
1707
1708 var headCommit *GitCommit
1709
1710 // Filter out commits we've already seen
1711 for _, commit := range parsedCommits {
1712 if commit.Hash == head {
1713 headCommit = &commit
1714 }
1715
1716 // Skip if we've seen this commit before. If our head has changed, always include that.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001717 if ags.seenCommits[commit.Hash] && commit.Hash != head {
Earl Lee2e463fb2025-04-17 11:22:22 -07001718 continue
1719 }
1720
1721 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001722 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001723
1724 // Add to our list of new commits
1725 commits = append(commits, &commit)
1726 }
1727
Philip Zeyligerf2872992025-05-22 10:35:28 -07001728 if ags.gitRemoteAddr != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001729 if headCommit == nil {
1730 // I think this can only happen if we have a bug or if there's a race.
1731 headCommit = &GitCommit{}
1732 headCommit.Hash = head
1733 headCommit.Subject = "unknown"
1734 commits = append(commits, headCommit)
1735 }
1736
Philip Zeyligerf2872992025-05-22 10:35:28 -07001737 originalBranch := cmp.Or(ags.branchName, "sketch/"+sessionID)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001738 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001739
1740 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1741 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1742 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001743
1744 // Try up to 10 times with different branch names if the branch is checked out on the remote
1745 var out []byte
1746 var err error
1747 for retries := range 10 {
1748 if retries > 0 {
1749 // Add a numeric suffix to the branch name
1750 branch = fmt.Sprintf("%s%d", originalBranch, retries)
1751 }
1752
Philip Zeyligerf2872992025-05-22 10:35:28 -07001753 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1754 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001755 out, err = cmd.CombinedOutput()
1756
1757 if err == nil {
1758 // Success! Break out of the retry loop
1759 break
1760 }
1761
1762 // Check if this is the "refusing to update checked out branch" error
1763 if !strings.Contains(string(out), "refusing to update checked out branch") {
1764 // This is a different error, so don't retry
1765 break
1766 }
1767
1768 // If we're on the last retry, we'll report the error
1769 if retries == 9 {
1770 break
1771 }
1772 }
1773
1774 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001775 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001776 } else {
1777 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001778 // Update the agent's branch name if we ended up using a different one
1779 if branch != originalBranch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001780 ags.branchName = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001781 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001782 }
1783 }
1784
1785 // If we found new commits, create a message
1786 if len(commits) > 0 {
1787 msg := AgentMessage{
1788 Type: CommitMessageType,
1789 Timestamp: time.Now(),
1790 Commits: commits,
1791 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001792 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001793 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001794 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001795}
1796
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001797func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001798 return strings.Map(func(r rune) rune {
1799 // lowercase
1800 if r >= 'A' && r <= 'Z' {
1801 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001802 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001803 // replace spaces with dashes
1804 if r == ' ' {
1805 return '-'
1806 }
1807 // allow alphanumerics and dashes
1808 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1809 return r
1810 }
1811 return -1
1812 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001813}
1814
1815// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1816// and returns an array of GitCommit structs.
1817func parseGitLog(output string) []GitCommit {
1818 var commits []GitCommit
1819
1820 // No output means no commits
1821 if len(output) == 0 {
1822 return commits
1823 }
1824
1825 // Split by NULL byte
1826 parts := strings.Split(output, "\x00")
1827
1828 // Process in triplets (hash, subject, body)
1829 for i := 0; i < len(parts); i++ {
1830 // Skip empty parts
1831 if parts[i] == "" {
1832 continue
1833 }
1834
1835 // This should be a hash
1836 hash := strings.TrimSpace(parts[i])
1837
1838 // Make sure we have at least a subject part available
1839 if i+1 >= len(parts) {
1840 break // No more parts available
1841 }
1842
1843 // Get the subject
1844 subject := strings.TrimSpace(parts[i+1])
1845
1846 // Get the body if available
1847 body := ""
1848 if i+2 < len(parts) {
1849 body = strings.TrimSpace(parts[i+2])
1850 }
1851
1852 // Skip to the next triplet
1853 i += 2
1854
1855 commits = append(commits, GitCommit{
1856 Hash: hash,
1857 Subject: subject,
1858 Body: body,
1859 })
1860 }
1861
1862 return commits
1863}
1864
1865func repoRoot(ctx context.Context, dir string) (string, error) {
1866 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1867 stderr := new(strings.Builder)
1868 cmd.Stderr = stderr
1869 cmd.Dir = dir
1870 out, err := cmd.Output()
1871 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001872 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07001873 }
1874 return strings.TrimSpace(string(out)), nil
1875}
1876
1877func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1878 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1879 stderr := new(strings.Builder)
1880 cmd.Stderr = stderr
1881 cmd.Dir = dir
1882 out, err := cmd.Output()
1883 if err != nil {
1884 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1885 }
1886 // TODO: validate that out is valid hex
1887 return strings.TrimSpace(string(out)), nil
1888}
1889
1890// isValidGitSHA validates if a string looks like a valid git SHA hash.
1891// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1892func isValidGitSHA(sha string) bool {
1893 // Git SHA must be a hexadecimal string with at least 4 characters
1894 if len(sha) < 4 || len(sha) > 40 {
1895 return false
1896 }
1897
1898 // Check if the string only contains hexadecimal characters
1899 for _, char := range sha {
1900 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1901 return false
1902 }
1903 }
1904
1905 return true
1906}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001907
1908// getGitOrigin returns the URL of the git remote 'origin' if it exists
1909func getGitOrigin(ctx context.Context, dir string) string {
1910 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1911 cmd.Dir = dir
1912 stderr := new(strings.Builder)
1913 cmd.Stderr = stderr
1914 out, err := cmd.Output()
1915 if err != nil {
1916 return ""
1917 }
1918 return strings.TrimSpace(string(out))
1919}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001920
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001921// systemPromptData contains the data used to render the system prompt template
1922type systemPromptData struct {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001923 ClientGOOS string
1924 ClientGOARCH string
1925 WorkingDir string
1926 RepoRoot string
1927 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001928 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001929}
1930
1931// renderSystemPrompt renders the system prompt template.
1932func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001933 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001934 ClientGOOS: a.config.ClientGOOS,
1935 ClientGOARCH: a.config.ClientGOARCH,
1936 WorkingDir: a.workingDir,
1937 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07001938 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001939 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001940 }
1941
1942 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1943 if err != nil {
1944 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1945 }
1946 buf := new(strings.Builder)
1947 err = tmpl.Execute(buf, data)
1948 if err != nil {
1949 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1950 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001951 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001952 return buf.String()
1953}
Philip Zeyligereab12de2025-05-14 02:35:53 +00001954
1955// StateTransitionIterator provides an iterator over state transitions.
1956type StateTransitionIterator interface {
1957 // Next blocks until a new state transition is available or context is done.
1958 // Returns nil if the context is cancelled.
1959 Next() *StateTransition
1960 // Close removes the listener and cleans up resources.
1961 Close()
1962}
1963
1964// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
1965type StateTransitionIteratorImpl struct {
1966 agent *Agent
1967 ctx context.Context
1968 ch chan StateTransition
1969 unsubscribe func()
1970}
1971
1972// Next blocks until a new state transition is available or the context is cancelled.
1973func (s *StateTransitionIteratorImpl) Next() *StateTransition {
1974 select {
1975 case <-s.ctx.Done():
1976 return nil
1977 case transition, ok := <-s.ch:
1978 if !ok {
1979 return nil
1980 }
1981 transitionCopy := transition
1982 return &transitionCopy
1983 }
1984}
1985
1986// Close removes the listener and cleans up resources.
1987func (s *StateTransitionIteratorImpl) Close() {
1988 if s.unsubscribe != nil {
1989 s.unsubscribe()
1990 s.unsubscribe = nil
1991 }
1992}
1993
1994// NewStateTransitionIterator returns an iterator that receives state transitions.
1995func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
1996 a.mu.Lock()
1997 defer a.mu.Unlock()
1998
1999 // Create channel to receive state transitions
2000 ch := make(chan StateTransition, 10)
2001
2002 // Add a listener to the state machine
2003 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2004
2005 return &StateTransitionIteratorImpl{
2006 agent: a,
2007 ctx: ctx,
2008 ch: ch,
2009 unsubscribe: unsubscribe,
2010 }
2011}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002012
2013// setupGitHooks creates or updates git hooks in the specified working directory.
2014func setupGitHooks(workingDir string) error {
2015 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2016
2017 _, err := os.Stat(hooksDir)
2018 if os.IsNotExist(err) {
2019 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2020 }
2021 if err != nil {
2022 return fmt.Errorf("error checking git hooks directory: %w", err)
2023 }
2024
2025 // Define the post-commit hook content
2026 postCommitHook := `#!/bin/bash
2027echo "<post_commit_hook>"
2028echo "Please review this commit message and fix it if it is incorrect."
2029echo "This hook only echos the commit message; it does not modify it."
2030echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2031echo "<last_commit_message>"
2032git log -1 --pretty=%B
2033echo "</last_commit_message>"
2034echo "</post_commit_hook>"
2035`
2036
2037 // Define the prepare-commit-msg hook content
2038 prepareCommitMsgHook := `#!/bin/bash
2039# Add Co-Authored-By and Change-ID trailers to commit messages
2040# Check if these trailers already exist before adding them
2041
2042commit_file="$1"
2043COMMIT_SOURCE="$2"
2044
2045# Skip for merges, squashes, or when using a commit template
2046if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2047 [ "$COMMIT_SOURCE" = "squash" ]; then
2048 exit 0
2049fi
2050
2051commit_msg=$(cat "$commit_file")
2052
2053needs_co_author=true
2054needs_change_id=true
2055
2056# Check if commit message already has Co-Authored-By trailer
2057if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2058 needs_co_author=false
2059fi
2060
2061# Check if commit message already has Change-ID trailer
2062if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2063 needs_change_id=false
2064fi
2065
2066# Only modify if at least one trailer needs to be added
2067if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002068 # Ensure there's a proper blank line before trailers
2069 if [ -s "$commit_file" ]; then
2070 # Check if file ends with newline by reading last character
2071 last_char=$(tail -c 1 "$commit_file")
2072
2073 if [ "$last_char" != "" ]; then
2074 # File doesn't end with newline - add two newlines (complete line + blank line)
2075 echo "" >> "$commit_file"
2076 echo "" >> "$commit_file"
2077 else
2078 # File ends with newline - check if we already have a blank line
2079 last_line=$(tail -1 "$commit_file")
2080 if [ -n "$last_line" ]; then
2081 # Last line has content - add one newline for blank line
2082 echo "" >> "$commit_file"
2083 fi
2084 # If last line is empty, we already have a blank line - don't add anything
2085 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002086 fi
2087
2088 # Add trailers if needed
2089 if [ "$needs_co_author" = true ]; then
2090 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2091 fi
2092
2093 if [ "$needs_change_id" = true ]; then
2094 change_id=$(openssl rand -hex 8)
2095 echo "Change-ID: s${change_id}k" >> "$commit_file"
2096 fi
2097fi
2098`
2099
2100 // Update or create the post-commit hook
2101 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2102 if err != nil {
2103 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2104 }
2105
2106 // Update or create the prepare-commit-msg hook
2107 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2108 if err != nil {
2109 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2110 }
2111
2112 return nil
2113}
2114
2115// updateOrCreateHook creates a new hook file or updates an existing one
2116// by appending the new content if it doesn't already contain it.
2117func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2118 // Check if the hook already exists
2119 buf, err := os.ReadFile(hookPath)
2120 if os.IsNotExist(err) {
2121 // Hook doesn't exist, create it
2122 err = os.WriteFile(hookPath, []byte(content), 0o755)
2123 if err != nil {
2124 return fmt.Errorf("failed to create hook: %w", err)
2125 }
2126 return nil
2127 }
2128 if err != nil {
2129 return fmt.Errorf("error reading existing hook: %w", err)
2130 }
2131
2132 // Hook exists, check if our content is already in it by looking for a distinctive line
2133 code := string(buf)
2134 if strings.Contains(code, distinctiveLine) {
2135 // Already contains our content, nothing to do
2136 return nil
2137 }
2138
2139 // Append our content to the existing hook
2140 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2141 if err != nil {
2142 return fmt.Errorf("failed to open hook for appending: %w", err)
2143 }
2144 defer f.Close()
2145
2146 // Ensure there's a newline at the end of the existing content if needed
2147 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2148 _, err = f.WriteString("\n")
2149 if err != nil {
2150 return fmt.Errorf("failed to add newline to hook: %w", err)
2151 }
2152 }
2153
2154 // Add a separator before our content
2155 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2156 if err != nil {
2157 return fmt.Errorf("failed to append to hook: %w", err)
2158 }
2159
2160 return nil
2161}