blob: a400f221f75a152ecbb186b728bc0f7e0868aa1c [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package loop
2
3import (
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07004 "cmp"
Earl Lee2e463fb2025-04-17 11:22:22 -07005 "context"
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07006 _ "embed"
Earl Lee2e463fb2025-04-17 11:22:22 -07007 "encoding/json"
8 "fmt"
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +00009 "io"
Earl Lee2e463fb2025-04-17 11:22:22 -070010 "log/slog"
11 "net/http"
12 "os"
13 "os/exec"
Pokey Rule7a113622025-05-12 10:58:45 +010014 "path/filepath"
Earl Lee2e463fb2025-04-17 11:22:22 -070015 "runtime/debug"
16 "slices"
17 "strings"
18 "sync"
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +000019 "text/template"
Earl Lee2e463fb2025-04-17 11:22:22 -070020 "time"
21
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000022 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070023 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000024 "sketch.dev/claudetool/bashkit"
Autoformatter4962f152025-05-06 17:24:20 +000025 "sketch.dev/claudetool/browse"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000026 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydera997be62025-05-07 22:52:46 +000027 "sketch.dev/claudetool/onstart"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070028 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070029 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070030 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070031)
32
33const (
34 userCancelMessage = "user requested agent to stop handling responses"
35)
36
Philip Zeyligerb7c58752025-05-01 10:10:17 -070037type MessageIterator interface {
38 // Next blocks until the next message is available. It may
39 // return nil if the underlying iterator context is done.
40 Next() *AgentMessage
41 Close()
42}
43
Earl Lee2e463fb2025-04-17 11:22:22 -070044type CodingAgent interface {
45 // Init initializes an agent inside a docker container.
46 Init(AgentInit) error
47
48 // Ready returns a channel closed after Init successfully called.
49 Ready() <-chan struct{}
50
51 // URL reports the HTTP URL of this agent.
52 URL() string
53
54 // UserMessage enqueues a message to the agent and returns immediately.
55 UserMessage(ctx context.Context, msg string)
56
Philip Zeyligerb7c58752025-05-01 10:10:17 -070057 // Returns an iterator that finishes when the context is done and
58 // starts with the given message index.
59 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070060
Philip Zeyligereab12de2025-05-14 02:35:53 +000061 // Returns an iterator that notifies of state transitions until the context is done.
62 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
63
Earl Lee2e463fb2025-04-17 11:22:22 -070064 // Loop begins the agent loop returns only when ctx is cancelled.
65 Loop(ctx context.Context)
66
Sean McCulloughedc88dc2025-04-30 02:55:01 +000067 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070068
69 CancelToolUse(toolUseID string, cause error) error
70
71 // Returns a subset of the agent's message history.
72 Messages(start int, end int) []AgentMessage
73
74 // Returns the current number of messages in the history
75 MessageCount() int
76
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070077 TotalUsage() conversation.CumulativeUsage
78 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070079
Earl Lee2e463fb2025-04-17 11:22:22 -070080 WorkingDir() string
81
82 // Diff returns a unified diff of changes made since the agent was instantiated.
83 // If commit is non-nil, it shows the diff for just that specific commit.
84 Diff(commit *string) (string, error)
85
Philip Zeyliger49edc922025-05-14 09:45:45 -070086 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
87 // starts out as the commit where sketch started, but a user can move it if need
88 // be, for example in the case of a rebase. It is stored as a git tag.
89 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070090
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000091 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
92 // (Typically, this is "sketch-base")
93 SketchGitBaseRef() string
94
Earl Lee2e463fb2025-04-17 11:22:22 -070095 // Title returns the current title of the conversation.
96 Title() string
97
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000098 // BranchName returns the git branch name for the conversation.
99 BranchName() string
100
Earl Lee2e463fb2025-04-17 11:22:22 -0700101 // OS returns the operating system of the client.
102 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000103
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000104 // SessionID returns the unique session identifier.
105 SessionID() string
106
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000107 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
108 OutstandingLLMCallCount() int
109
110 // OutstandingToolCalls returns the names of outstanding tool calls.
111 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000112 OutsideOS() string
113 OutsideHostname() string
114 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000115 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000116 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
117 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700118
119 // RestartConversation resets the conversation history
120 RestartConversation(ctx context.Context, rev string, initialPrompt string) error
121 // SuggestReprompt suggests a re-prompt based on the current conversation.
122 SuggestReprompt(ctx context.Context) (string, error)
123 // 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
Earl Lee2e463fb2025-04-17 11:22:22 -0700129}
130
131type CodingAgentMessageType string
132
133const (
134 UserMessageType CodingAgentMessageType = "user"
135 AgentMessageType CodingAgentMessageType = "agent"
136 ErrorMessageType CodingAgentMessageType = "error"
137 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
138 ToolUseMessageType CodingAgentMessageType = "tool"
139 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
140 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
141
142 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
143)
144
145type AgentMessage struct {
146 Type CodingAgentMessageType `json:"type"`
147 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
148 EndOfTurn bool `json:"end_of_turn"`
149
150 Content string `json:"content"`
151 ToolName string `json:"tool_name,omitempty"`
152 ToolInput string `json:"input,omitempty"`
153 ToolResult string `json:"tool_result,omitempty"`
154 ToolError bool `json:"tool_error,omitempty"`
155 ToolCallId string `json:"tool_call_id,omitempty"`
156
157 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
158 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
159
Sean McCulloughd9f13372025-04-21 15:08:49 -0700160 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
161 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
162
Earl Lee2e463fb2025-04-17 11:22:22 -0700163 // Commits is a list of git commits for a commit message
164 Commits []*GitCommit `json:"commits,omitempty"`
165
166 Timestamp time.Time `json:"timestamp"`
167 ConversationID string `json:"conversation_id"`
168 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700169 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700170
171 // Message timing information
172 StartTime *time.Time `json:"start_time,omitempty"`
173 EndTime *time.Time `json:"end_time,omitempty"`
174 Elapsed *time.Duration `json:"elapsed,omitempty"`
175
176 // Turn duration - the time taken for a complete agent turn
177 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
178
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000179 // HideOutput indicates that this message should not be rendered in the UI.
180 // This is useful for subconversations that generate output that shouldn't be shown to the user.
181 HideOutput bool `json:"hide_output,omitempty"`
182
Earl Lee2e463fb2025-04-17 11:22:22 -0700183 Idx int `json:"idx"`
184}
185
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000186// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700187func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700188 if convo == nil {
189 m.ConversationID = ""
190 m.ParentConversationID = nil
191 return
192 }
193 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000194 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700195 if convo.Parent != nil {
196 m.ParentConversationID = &convo.Parent.ID
197 }
198}
199
Earl Lee2e463fb2025-04-17 11:22:22 -0700200// GitCommit represents a single git commit for a commit message
201type GitCommit struct {
202 Hash string `json:"hash"` // Full commit hash
203 Subject string `json:"subject"` // Commit subject line
204 Body string `json:"body"` // Full commit message body
205 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
206}
207
208// ToolCall represents a single tool call within an agent message
209type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700210 Name string `json:"name"`
211 Input string `json:"input"`
212 ToolCallId string `json:"tool_call_id"`
213 ResultMessage *AgentMessage `json:"result_message,omitempty"`
214 Args string `json:"args,omitempty"`
215 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700216}
217
218func (a *AgentMessage) Attr() slog.Attr {
219 var attrs []any = []any{
220 slog.String("type", string(a.Type)),
221 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700222 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700223 if a.EndOfTurn {
224 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
225 }
226 if a.Content != "" {
227 attrs = append(attrs, slog.String("content", a.Content))
228 }
229 if a.ToolName != "" {
230 attrs = append(attrs, slog.String("tool_name", a.ToolName))
231 }
232 if a.ToolInput != "" {
233 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
234 }
235 if a.Elapsed != nil {
236 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
237 }
238 if a.TurnDuration != nil {
239 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
240 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700241 if len(a.ToolResult) > 0 {
242 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700243 }
244 if a.ToolError {
245 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
246 }
247 if len(a.ToolCalls) > 0 {
248 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
249 for i, tc := range a.ToolCalls {
250 toolCallAttrs = append(toolCallAttrs, slog.Group(
251 fmt.Sprintf("tool_call_%d", i),
252 slog.String("name", tc.Name),
253 slog.String("input", tc.Input),
254 ))
255 }
256 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
257 }
258 if a.ConversationID != "" {
259 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
260 }
261 if a.ParentConversationID != nil {
262 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
263 }
264 if a.Usage != nil && !a.Usage.IsZero() {
265 attrs = append(attrs, a.Usage.Attr())
266 }
267 // TODO: timestamp, convo ids, idx?
268 return slog.Group("agent_message", attrs...)
269}
270
271func errorMessage(err error) AgentMessage {
272 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
273 if os.Getenv(("DEBUG")) == "1" {
274 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
275 }
276
277 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
278}
279
280func budgetMessage(err error) AgentMessage {
281 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
282}
283
284// ConvoInterface defines the interface for conversation interactions
285type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700286 CumulativeUsage() conversation.CumulativeUsage
287 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700288 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700289 SendMessage(message llm.Message) (*llm.Response, error)
290 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700291 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000292 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700293 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700294 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700295 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700296}
297
298type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700299 convo ConvoInterface
300 config AgentConfig // config for this agent
301 workingDir string
302 repoRoot string // workingDir may be a subdir of repoRoot
303 url string
304 firstMessageIndex int // index of the first message in the current conversation
305 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700306 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000307 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700308 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000309 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700310 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700311 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700312 title string
313 branchName string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000314 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700315 // State machine to track agent state
316 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000317 // Outside information
318 outsideHostname string
319 outsideOS string
320 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000321 // URL of the git remote 'origin' if it exists
322 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700323
324 // Time when the current turn started (reset at the beginning of InnerLoop)
325 startOfTurn time.Time
326
327 // Inbox - for messages from the user to the agent.
328 // sent on by UserMessage
329 // . e.g. when user types into the chat textarea
330 // read from by GatherMessages
331 inbox chan string
332
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000333 // protects cancelTurn
334 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700335 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000336 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700337
338 // protects following
339 mu sync.Mutex
340
341 // Stores all messages for this agent
342 history []AgentMessage
343
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700344 // Iterators add themselves here when they're ready to be notified of new messages.
345 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700346
347 // Track git commits we've already seen (by hash)
348 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000349
350 // Track outstanding LLM call IDs
351 outstandingLLMCalls map[string]struct{}
352
353 // Track outstanding tool calls by ID with their names
354 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700355}
356
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700357// NewIterator implements CodingAgent.
358func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
359 a.mu.Lock()
360 defer a.mu.Unlock()
361
362 return &MessageIteratorImpl{
363 agent: a,
364 ctx: ctx,
365 nextMessageIdx: nextMessageIdx,
366 ch: make(chan *AgentMessage, 100),
367 }
368}
369
370type MessageIteratorImpl struct {
371 agent *Agent
372 ctx context.Context
373 nextMessageIdx int
374 ch chan *AgentMessage
375 subscribed bool
376}
377
378func (m *MessageIteratorImpl) Close() {
379 m.agent.mu.Lock()
380 defer m.agent.mu.Unlock()
381 // Delete ourselves from the subscribers list
382 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
383 return x == m.ch
384 })
385 close(m.ch)
386}
387
388func (m *MessageIteratorImpl) Next() *AgentMessage {
389 // We avoid subscription at creation to let ourselves catch up to "current state"
390 // before subscribing.
391 if !m.subscribed {
392 m.agent.mu.Lock()
393 if m.nextMessageIdx < len(m.agent.history) {
394 msg := &m.agent.history[m.nextMessageIdx]
395 m.nextMessageIdx++
396 m.agent.mu.Unlock()
397 return msg
398 }
399 // The next message doesn't exist yet, so let's subscribe
400 m.agent.subscribers = append(m.agent.subscribers, m.ch)
401 m.subscribed = true
402 m.agent.mu.Unlock()
403 }
404
405 for {
406 select {
407 case <-m.ctx.Done():
408 m.agent.mu.Lock()
409 // Delete ourselves from the subscribers list
410 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
411 return x == m.ch
412 })
413 m.subscribed = false
414 m.agent.mu.Unlock()
415 return nil
416 case msg, ok := <-m.ch:
417 if !ok {
418 // Close may have been called
419 return nil
420 }
421 if msg.Idx == m.nextMessageIdx {
422 m.nextMessageIdx++
423 return msg
424 }
425 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
426 panic("out of order message")
427 }
428 }
429}
430
Sean McCulloughd9d45812025-04-30 16:53:41 -0700431// Assert that Agent satisfies the CodingAgent interface.
432var _ CodingAgent = &Agent{}
433
434// StateName implements CodingAgent.
435func (a *Agent) CurrentStateName() string {
436 if a.stateMachine == nil {
437 return ""
438 }
439 return a.stateMachine.currentState.String()
440}
441
Earl Lee2e463fb2025-04-17 11:22:22 -0700442func (a *Agent) URL() string { return a.url }
443
444// Title returns the current title of the conversation.
445// If no title has been set, returns an empty string.
446func (a *Agent) Title() string {
447 a.mu.Lock()
448 defer a.mu.Unlock()
449 return a.title
450}
451
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000452// BranchName returns the git branch name for the conversation.
453func (a *Agent) BranchName() string {
454 a.mu.Lock()
455 defer a.mu.Unlock()
456 return a.branchName
457}
458
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000459// OutstandingLLMCallCount returns the number of outstanding LLM calls.
460func (a *Agent) OutstandingLLMCallCount() int {
461 a.mu.Lock()
462 defer a.mu.Unlock()
463 return len(a.outstandingLLMCalls)
464}
465
466// OutstandingToolCalls returns the names of outstanding tool calls.
467func (a *Agent) OutstandingToolCalls() []string {
468 a.mu.Lock()
469 defer a.mu.Unlock()
470
471 tools := make([]string, 0, len(a.outstandingToolCalls))
472 for _, toolName := range a.outstandingToolCalls {
473 tools = append(tools, toolName)
474 }
475 return tools
476}
477
Earl Lee2e463fb2025-04-17 11:22:22 -0700478// OS returns the operating system of the client.
479func (a *Agent) OS() string {
480 return a.config.ClientGOOS
481}
482
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000483func (a *Agent) SessionID() string {
484 return a.config.SessionID
485}
486
Philip Zeyliger18532b22025-04-23 21:11:46 +0000487// OutsideOS returns the operating system of the outside system.
488func (a *Agent) OutsideOS() string {
489 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000490}
491
Philip Zeyliger18532b22025-04-23 21:11:46 +0000492// OutsideHostname returns the hostname of the outside system.
493func (a *Agent) OutsideHostname() string {
494 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000495}
496
Philip Zeyliger18532b22025-04-23 21:11:46 +0000497// OutsideWorkingDir returns the working directory on the outside system.
498func (a *Agent) OutsideWorkingDir() string {
499 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000500}
501
502// GitOrigin returns the URL of the git remote 'origin' if it exists.
503func (a *Agent) GitOrigin() string {
504 return a.gitOrigin
505}
506
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000507func (a *Agent) OpenBrowser(url string) {
508 if !a.IsInContainer() {
509 browser.Open(url)
510 return
511 }
512 // We're in Docker, need to send a request to the Git server
513 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700514 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000515 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700516 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000517 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700518 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000519 return
520 }
521 defer resp.Body.Close()
522 if resp.StatusCode == http.StatusOK {
523 return
524 }
525 body, _ := io.ReadAll(resp.Body)
526 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
527}
528
Sean McCullough96b60dd2025-04-30 09:49:10 -0700529// CurrentState returns the current state of the agent's state machine.
530func (a *Agent) CurrentState() State {
531 return a.stateMachine.CurrentState()
532}
533
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700534func (a *Agent) IsInContainer() bool {
535 return a.config.InDocker
536}
537
538func (a *Agent) FirstMessageIndex() int {
539 a.mu.Lock()
540 defer a.mu.Unlock()
541 return a.firstMessageIndex
542}
543
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000544// SetTitle sets the title of the conversation.
545func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700546 a.mu.Lock()
547 defer a.mu.Unlock()
548 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000549}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700550
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000551// SetBranch sets the branch name of the conversation.
552func (a *Agent) SetBranch(branchName string) {
553 a.mu.Lock()
554 defer a.mu.Unlock()
555 a.branchName = branchName
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000556 convo, ok := a.convo.(*conversation.Convo)
557 if ok {
558 convo.ExtraData["branch"] = branchName
559 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700560}
561
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000562// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700563func (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 +0000564 // Track the tool call
565 a.mu.Lock()
566 a.outstandingToolCalls[id] = toolName
567 a.mu.Unlock()
568}
569
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700570// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
571// If there's only one element in the array and it's a text type, it returns that text directly.
572// It also processes nested ToolResult arrays recursively.
573func contentToString(contents []llm.Content) string {
574 if len(contents) == 0 {
575 return ""
576 }
577
578 // If there's only one element and it's a text type, return it directly
579 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
580 return contents[0].Text
581 }
582
583 // Otherwise, concatenate all text content
584 var result strings.Builder
585 for _, content := range contents {
586 if content.Type == llm.ContentTypeText {
587 result.WriteString(content.Text)
588 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
589 // Recursively process nested tool results
590 result.WriteString(contentToString(content.ToolResult))
591 }
592 }
593
594 return result.String()
595}
596
Earl Lee2e463fb2025-04-17 11:22:22 -0700597// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700598func (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 +0000599 // Remove the tool call from outstanding calls
600 a.mu.Lock()
601 delete(a.outstandingToolCalls, toolID)
602 a.mu.Unlock()
603
Earl Lee2e463fb2025-04-17 11:22:22 -0700604 m := AgentMessage{
605 Type: ToolUseMessageType,
606 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700607 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700608 ToolError: content.ToolError,
609 ToolName: toolName,
610 ToolInput: string(toolInput),
611 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700612 StartTime: content.ToolUseStartTime,
613 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700614 }
615
616 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700617 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
618 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700619 m.Elapsed = &elapsed
620 }
621
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700622 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700623 a.pushToOutbox(ctx, m)
624}
625
626// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700627func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000628 a.mu.Lock()
629 defer a.mu.Unlock()
630 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700631 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
632}
633
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700634// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700635// that need to be displayed (as well as tool calls that we send along when
636// they're done). (It would be reasonable to also mention tool calls when they're
637// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700638func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000639 // Remove the LLM call from outstanding calls
640 a.mu.Lock()
641 delete(a.outstandingLLMCalls, id)
642 a.mu.Unlock()
643
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700644 if resp == nil {
645 // LLM API call failed
646 m := AgentMessage{
647 Type: ErrorMessageType,
648 Content: "API call failed, type 'continue' to try again",
649 }
650 m.SetConvo(convo)
651 a.pushToOutbox(ctx, m)
652 return
653 }
654
Earl Lee2e463fb2025-04-17 11:22:22 -0700655 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700656 if convo.Parent == nil { // subconvos never end the turn
657 switch resp.StopReason {
658 case llm.StopReasonToolUse:
659 // Check whether any of the tool calls are for tools that should end the turn
660 ToolSearch:
661 for _, part := range resp.Content {
662 if part.Type != llm.ContentTypeToolUse {
663 continue
664 }
Sean McCullough021557a2025-05-05 23:20:53 +0000665 // Find the tool by name
666 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700667 if tool.Name == part.ToolName {
668 endOfTurn = tool.EndsTurn
669 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000670 }
671 }
Sean McCullough021557a2025-05-05 23:20:53 +0000672 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700673 default:
674 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000675 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700676 }
677 m := AgentMessage{
678 Type: AgentMessageType,
679 Content: collectTextContent(resp),
680 EndOfTurn: endOfTurn,
681 Usage: &resp.Usage,
682 StartTime: resp.StartTime,
683 EndTime: resp.EndTime,
684 }
685
686 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700687 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700688 var toolCalls []ToolCall
689 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700690 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700691 toolCalls = append(toolCalls, ToolCall{
692 Name: part.ToolName,
693 Input: string(part.ToolInput),
694 ToolCallId: part.ID,
695 })
696 }
697 }
698 m.ToolCalls = toolCalls
699 }
700
701 // Calculate the elapsed time if both start and end times are set
702 if resp.StartTime != nil && resp.EndTime != nil {
703 elapsed := resp.EndTime.Sub(*resp.StartTime)
704 m.Elapsed = &elapsed
705 }
706
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700707 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700708 a.pushToOutbox(ctx, m)
709}
710
711// WorkingDir implements CodingAgent.
712func (a *Agent) WorkingDir() string {
713 return a.workingDir
714}
715
716// MessageCount implements CodingAgent.
717func (a *Agent) MessageCount() int {
718 a.mu.Lock()
719 defer a.mu.Unlock()
720 return len(a.history)
721}
722
723// Messages implements CodingAgent.
724func (a *Agent) Messages(start int, end int) []AgentMessage {
725 a.mu.Lock()
726 defer a.mu.Unlock()
727 return slices.Clone(a.history[start:end])
728}
729
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700730func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700731 return a.originalBudget
732}
733
734// AgentConfig contains configuration for creating a new Agent.
735type AgentConfig struct {
736 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700737 Service llm.Service
738 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700739 GitUsername string
740 GitEmail string
741 SessionID string
742 ClientGOOS string
743 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700744 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700745 UseAnthropicEdit bool
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000746 OneShot bool
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700747 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000748 // Outside information
749 OutsideHostname string
750 OutsideOS string
751 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700752
753 // Outtie's HTTP to, e.g., open a browser
754 OutsideHTTP string
755 // Outtie's Git server
756 GitRemoteAddr string
757 // Commit to checkout from Outtie
758 Commit string
Earl Lee2e463fb2025-04-17 11:22:22 -0700759}
760
761// NewAgent creates a new Agent.
762// It is not usable until Init() is called.
763func NewAgent(config AgentConfig) *Agent {
764 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000765 config: config,
766 ready: make(chan struct{}),
767 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700768 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000769 startedAt: time.Now(),
770 originalBudget: config.Budget,
771 seenCommits: make(map[string]bool),
772 outsideHostname: config.OutsideHostname,
773 outsideOS: config.OutsideOS,
774 outsideWorkingDir: config.OutsideWorkingDir,
775 outstandingLLMCalls: make(map[string]struct{}),
776 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700777 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700778 workingDir: config.WorkingDir,
779 outsideHTTP: config.OutsideHTTP,
780 gitRemoteAddr: config.GitRemoteAddr,
Earl Lee2e463fb2025-04-17 11:22:22 -0700781 }
782 return agent
783}
784
785type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700786 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -0700787
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700788 InDocker bool
789 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -0700790}
791
792func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700793 if a.convo != nil {
794 return fmt.Errorf("Agent.Init: already initialized")
795 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700796 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -0700797 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700798
799 // Fetch, if so configured.
800 if ini.InDocker && a.config.Commit != "" && a.config.GitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -0700801 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
802
Earl Lee2e463fb2025-04-17 11:22:22 -0700803 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700804 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -0700805 if out, err := cmd.CombinedOutput(); err != nil {
806 return fmt.Errorf("git stash: %s: %v", out, err)
807 }
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700808 // sketch-host is a git repo hosted by "outtie sketch". When it notices a 'git fetch',
809 // it runs "git fetch" underneath the covers to get its latest commits. By configuring
810 // an additional remote.sketch-host.fetch, we make "origin/main" on innie sketch look like
811 // origin/main on outtie sketch, which should make it easier to rebase.
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700812 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", a.gitRemoteAddr)
813 cmd.Dir = a.workingDir
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700814 if out, err := cmd.CombinedOutput(); err != nil {
815 return fmt.Errorf("git remote add: %s: %v", out, err)
816 }
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700817 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.sketch-host.fetch",
818 "+refs/heads/feature/*:refs/remotes/origin/feature/*")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700819 cmd.Dir = a.workingDir
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700820 if out, err := cmd.CombinedOutput(); err != nil {
821 return fmt.Errorf("git config --add: %s: %v", out, err)
822 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000823 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700824 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -0700825 if out, err := cmd.CombinedOutput(); err != nil {
826 return fmt.Errorf("git fetch: %s: %w", out, err)
827 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700828 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
829 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100830 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
831 // Remove git hooks if they exist and retry
832 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700833 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +0100834 if _, statErr := os.Stat(hookPath); statErr == nil {
835 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
836 slog.String("error", err.Error()),
837 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700838 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +0100839 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
840 }
841
842 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700843 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
844 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100845 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700846 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 +0100847 }
848 } else {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700849 return fmt.Errorf("git checkout %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +0100850 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700851 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700852 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700853
854 if ini.HostAddr != "" {
855 a.url = "http://" + ini.HostAddr
856 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700857
858 if !ini.NoGit {
859 repoRoot, err := repoRoot(ctx, a.workingDir)
860 if err != nil {
861 return fmt.Errorf("repoRoot: %w", err)
862 }
863 a.repoRoot = repoRoot
864
Earl Lee2e463fb2025-04-17 11:22:22 -0700865 if err != nil {
866 return fmt.Errorf("resolveRef: %w", err)
867 }
Philip Zeyliger49edc922025-05-14 09:45:45 -0700868
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700869 if err := setupGitHooks(a.workingDir); err != nil {
870 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
871 }
872
Philip Zeyliger49edc922025-05-14 09:45:45 -0700873 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
874 cmd.Dir = repoRoot
875 if out, err := cmd.CombinedOutput(); err != nil {
876 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
877 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700878
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +0000879 slog.Info("running codebase analysis")
880 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
881 if err != nil {
882 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000883 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +0000884 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000885
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +0000886 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -0700887 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000888 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700889 }
890 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000891
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700892 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700893 }
Philip Zeyliger49edc922025-05-14 09:45:45 -0700894 a.lastHEAD = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -0700895 a.convo = a.initConvo()
896 close(a.ready)
897 return nil
898}
899
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700900//go:embed agent_system_prompt.txt
901var agentSystemPrompt string
902
Earl Lee2e463fb2025-04-17 11:22:22 -0700903// initConvo initializes the conversation.
904// It must not be called until all agent fields are initialized,
905// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700906func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700907 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700908 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700909 convo.PromptCaching = true
910 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000911 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000912 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -0700913
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000914 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
915 bashPermissionCheck := func(command string) error {
916 // Check if branch name is set
917 a.mu.Lock()
918 branchSet := a.branchName != ""
919 a.mu.Unlock()
920
921 // If branch is set, all commands are allowed
922 if branchSet {
923 return nil
924 }
925
926 // If branch is not set, check if this is a git commit command
927 willCommit, err := bashkit.WillRunGitCommit(command)
928 if err != nil {
929 // If there's an error checking, we should allow the command to proceed
930 return nil
931 }
932
933 // If it's a git commit and branch is not set, return an error
934 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000935 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000936 }
937
938 return nil
939 }
940
941 // Create a custom bash tool with the permission check
942 bashTool := claudetool.NewBashTool(bashPermissionCheck)
943
Earl Lee2e463fb2025-04-17 11:22:22 -0700944 // Register all tools with the conversation
945 // When adding, removing, or modifying tools here, double-check that the termui tool display
946 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000947
948 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -0700949 _, supportsScreenshots := a.config.Service.(*ant.Service)
950 var bTools []*llm.Tool
951 var browserCleanup func()
952
953 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
954 // Add cleanup function to context cancel
955 go func() {
956 <-a.config.Context.Done()
957 browserCleanup()
958 }()
959 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000960
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700961 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000962 bashTool, claudetool.Keyword,
Josh Bleecher Snyder93202652025-05-08 02:05:57 +0000963 claudetool.Think, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -0700964 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000965 }
966
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000967 // One-shot mode is non-interactive, multiple choice requires human response
968 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -0700969 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -0700970 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000971
972 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -0700973 if a.config.UseAnthropicEdit {
974 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
975 } else {
976 convo.Tools = append(convo.Tools, claudetool.Patch)
977 }
978 convo.Listener = a
979 return convo
980}
981
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -0700982var multipleChoiceTool = &llm.Tool{
983 Name: "multiplechoice",
984 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.",
985 EndsTurn: true,
986 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -0700987 "type": "object",
988 "description": "The question and a list of answers you would expect the user to choose from.",
989 "properties": {
990 "question": {
991 "type": "string",
992 "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?'"
993 },
994 "responseOptions": {
995 "type": "array",
996 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
997 "items": {
998 "type": "object",
999 "properties": {
1000 "caption": {
1001 "type": "string",
1002 "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'"
1003 },
1004 "responseText": {
1005 "type": "string",
1006 "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'"
1007 }
1008 },
1009 "required": ["caption", "responseText"]
1010 }
1011 }
1012 },
1013 "required": ["question", "responseOptions"]
1014}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001015 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1016 // The Run logic for "multiplechoice" tool is a no-op on the server.
1017 // The UI will present a list of options for the user to select from,
1018 // and that's it as far as "executing" the tool_use goes.
1019 // When the user *does* select one of the presented options, that
1020 // responseText gets sent as a chat message on behalf of the user.
1021 return llm.TextContent("end your turn and wait for the user to respond"), nil
1022 },
Sean McCullough485afc62025-04-28 14:28:39 -07001023}
1024
1025type MultipleChoiceOption struct {
1026 Caption string `json:"caption"`
1027 ResponseText string `json:"responseText"`
1028}
1029
1030type MultipleChoiceParams struct {
1031 Question string `json:"question"`
1032 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1033}
1034
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001035// branchExists reports whether branchName exists, either locally or in well-known remotes.
1036func branchExists(dir, branchName string) bool {
1037 refs := []string{
1038 "refs/heads/",
1039 "refs/remotes/origin/",
1040 "refs/remotes/sketch-host/",
1041 }
1042 for _, ref := range refs {
1043 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1044 cmd.Dir = dir
1045 if cmd.Run() == nil { // exit code 0 means branch exists
1046 return true
1047 }
1048 }
1049 return false
1050}
1051
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001052func (a *Agent) titleTool() *llm.Tool {
1053 description := `Sets the conversation title.`
1054 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001055 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001056 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001057 InputSchema: json.RawMessage(`{
1058 "type": "object",
1059 "properties": {
1060 "title": {
1061 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001062 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001063 }
1064 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001065 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001066}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001067 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001068 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001069 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001070 }
1071 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001072 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001073 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001074
1075 // We don't allow changing the title once set to be consistent with the previous behavior
1076 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001077 t := a.Title()
1078 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001079 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001080 }
1081
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001082 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001083 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001084 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001085
1086 a.SetTitle(params.Title)
1087 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001088 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001089 },
1090 }
1091 return titleTool
1092}
1093
1094func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001095 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 +00001096 preCommit := &llm.Tool{
1097 Name: "precommit",
1098 Description: description,
1099 InputSchema: json.RawMessage(`{
1100 "type": "object",
1101 "properties": {
1102 "branch_name": {
1103 "type": "string",
1104 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1105 }
1106 },
1107 "required": ["branch_name"]
1108}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001109 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001110 var params struct {
1111 BranchName string `json:"branch_name"`
1112 }
1113 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001114 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001115 }
1116
1117 b := a.BranchName()
1118 if b != "" {
Josh Bleecher Snyder44d1f1a2025-05-12 19:18:32 -07001119 return nil, fmt.Errorf("branch already set to %s; do not create a new branch", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001120 }
1121
1122 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001123 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001124 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001125 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001126 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001127 }
1128 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001129 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001130 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001131 }
1132
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001133 a.SetBranch(branchName)
Josh Bleecher Snyderf7bebdd2025-05-14 15:22:24 -07001134 response := fmt.Sprintf("switched to branch sketch/%q - DO NOT change branches unless explicitly requested", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001135
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001136 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1137 if err != nil {
1138 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1139 }
1140 if len(styleHint) > 0 {
1141 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001142 }
1143
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001144 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001145 },
1146 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001147 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001148}
1149
1150func (a *Agent) Ready() <-chan struct{} {
1151 return a.ready
1152}
1153
1154func (a *Agent) UserMessage(ctx context.Context, msg string) {
1155 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1156 a.inbox <- msg
1157}
1158
Earl Lee2e463fb2025-04-17 11:22:22 -07001159func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1160 return a.convo.CancelToolUse(toolUseID, cause)
1161}
1162
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001163func (a *Agent) CancelTurn(cause error) {
1164 a.cancelTurnMu.Lock()
1165 defer a.cancelTurnMu.Unlock()
1166 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001167 // Force state transition to cancelled state
1168 ctx := a.config.Context
1169 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001170 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001171 }
1172}
1173
1174func (a *Agent) Loop(ctxOuter context.Context) {
1175 for {
1176 select {
1177 case <-ctxOuter.Done():
1178 return
1179 default:
1180 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001181 a.cancelTurnMu.Lock()
1182 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001183 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001184 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001185 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001186 a.cancelTurn = cancel
1187 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001188 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1189 if err != nil {
1190 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1191 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001192 cancel(nil)
1193 }
1194 }
1195}
1196
1197func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1198 if m.Timestamp.IsZero() {
1199 m.Timestamp = time.Now()
1200 }
1201
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001202 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1203 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1204 m.Content = m.ToolResult
1205 }
1206
Earl Lee2e463fb2025-04-17 11:22:22 -07001207 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1208 if m.EndOfTurn && m.Type == AgentMessageType {
1209 turnDuration := time.Since(a.startOfTurn)
1210 m.TurnDuration = &turnDuration
1211 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1212 }
1213
Earl Lee2e463fb2025-04-17 11:22:22 -07001214 a.mu.Lock()
1215 defer a.mu.Unlock()
1216 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001217 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001218 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001219
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001220 // Notify all subscribers
1221 for _, ch := range a.subscribers {
1222 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001223 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001224}
1225
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001226func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1227 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001228 if block {
1229 select {
1230 case <-ctx.Done():
1231 return m, ctx.Err()
1232 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001233 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001234 }
1235 }
1236 for {
1237 select {
1238 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001239 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001240 default:
1241 return m, nil
1242 }
1243 }
1244}
1245
Sean McCullough885a16a2025-04-30 02:49:25 +00001246// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001247func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001248 // Reset the start of turn time
1249 a.startOfTurn = time.Now()
1250
Sean McCullough96b60dd2025-04-30 09:49:10 -07001251 // Transition to waiting for user input state
1252 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1253
Sean McCullough885a16a2025-04-30 02:49:25 +00001254 // Process initial user message
1255 initialResp, err := a.processUserMessage(ctx)
1256 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001257 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001258 return err
1259 }
1260
1261 // Handle edge case where both initialResp and err are nil
1262 if initialResp == nil {
1263 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001264 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1265
Sean McCullough9f4b8082025-04-30 17:34:07 +00001266 a.pushToOutbox(ctx, errorMessage(err))
1267 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001268 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001269
Earl Lee2e463fb2025-04-17 11:22:22 -07001270 // We do this as we go, but let's also do it at the end of the turn
1271 defer func() {
1272 if _, err := a.handleGitCommits(ctx); err != nil {
1273 // Just log the error, don't stop execution
1274 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1275 }
1276 }()
1277
Sean McCullougha1e0e492025-05-01 10:51:08 -07001278 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001279 resp := initialResp
1280 for {
1281 // Check if we are over budget
1282 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001283 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001284 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001285 }
1286
1287 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001288 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001289 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001290 break
1291 }
1292
Sean McCullough96b60dd2025-04-30 09:49:10 -07001293 // Transition to tool use requested state
1294 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1295
Sean McCullough885a16a2025-04-30 02:49:25 +00001296 // Handle tool execution
1297 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1298 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001299 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001300 }
1301
Sean McCullougha1e0e492025-05-01 10:51:08 -07001302 if toolResp == nil {
1303 return fmt.Errorf("cannot continue conversation with a nil tool response")
1304 }
1305
Sean McCullough885a16a2025-04-30 02:49:25 +00001306 // Set the response for the next iteration
1307 resp = toolResp
1308 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001309
1310 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001311}
1312
1313// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001314func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001315 // Wait for at least one message from the user
1316 msgs, err := a.GatherMessages(ctx, true)
1317 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001318 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001319 return nil, err
1320 }
1321
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001322 userMessage := llm.Message{
1323 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001324 Content: msgs,
1325 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001326
Sean McCullough96b60dd2025-04-30 09:49:10 -07001327 // Transition to sending to LLM state
1328 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1329
Sean McCullough885a16a2025-04-30 02:49:25 +00001330 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001331 resp, err := a.convo.SendMessage(userMessage)
1332 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001333 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001334 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001335 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001336 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001337
Sean McCullough96b60dd2025-04-30 09:49:10 -07001338 // Transition to processing LLM response state
1339 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1340
Sean McCullough885a16a2025-04-30 02:49:25 +00001341 return resp, nil
1342}
1343
1344// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001345func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1346 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001347 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001348 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001349
Sean McCullough96b60dd2025-04-30 09:49:10 -07001350 // Transition to checking for cancellation state
1351 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1352
Sean McCullough885a16a2025-04-30 02:49:25 +00001353 // Check if the operation was cancelled by the user
1354 select {
1355 case <-ctx.Done():
1356 // Don't actually run any of the tools, but rather build a response
1357 // for each tool_use message letting the LLM know that user canceled it.
1358 var err error
1359 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001360 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001361 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001362 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001363 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001364 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001365 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001366 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001367 // Transition to running tool state
1368 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1369
Sean McCullough885a16a2025-04-30 02:49:25 +00001370 // Add working directory to context for tool execution
1371 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1372
1373 // Execute the tools
1374 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001375 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001376 if ctx.Err() != nil { // e.g. the user canceled the operation
1377 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001378 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001379 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001380 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001381 a.pushToOutbox(ctx, errorMessage(err))
1382 }
1383 }
1384
1385 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001386 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001387 autoqualityMessages := a.processGitChanges(ctx)
1388
1389 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001390 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001391 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001392 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001393 return false, nil
1394 }
1395
1396 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001397 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1398 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001399}
1400
1401// processGitChanges checks for new git commits and runs autoformatters if needed
1402func (a *Agent) processGitChanges(ctx context.Context) []string {
1403 // Check for git commits after tool execution
1404 newCommits, err := a.handleGitCommits(ctx)
1405 if err != nil {
1406 // Just log the error, don't stop execution
1407 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1408 return nil
1409 }
1410
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001411 // Run mechanical checks if there was exactly one new commit.
1412 if len(newCommits) != 1 {
1413 return nil
1414 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001415 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001416 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1417 msg := a.codereview.RunMechanicalChecks(ctx)
1418 if msg != "" {
1419 a.pushToOutbox(ctx, AgentMessage{
1420 Type: AutoMessageType,
1421 Content: msg,
1422 Timestamp: time.Now(),
1423 })
1424 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001425 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001426
1427 return autoqualityMessages
1428}
1429
1430// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001431func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001432 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001433 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001434 msgs, err := a.GatherMessages(ctx, false)
1435 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001436 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001437 return false, nil
1438 }
1439
1440 // Inject any auto-generated messages from quality checks
1441 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001442 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001443 }
1444
1445 // Handle cancellation by appending a message about it
1446 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001447 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001448 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001449 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001450 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1451 } else if err := a.convo.OverBudget(); err != nil {
1452 // Handle budget issues by appending a message about it
1453 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 -07001454 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001455 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1456 }
1457
1458 // Combine tool results with user messages
1459 results = append(results, msgs...)
1460
1461 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001462 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001463 resp, err := a.convo.SendMessage(llm.Message{
1464 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001465 Content: results,
1466 })
1467 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001468 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001469 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1470 return true, nil // Return true to continue the conversation, but with no response
1471 }
1472
Sean McCullough96b60dd2025-04-30 09:49:10 -07001473 // Transition back to processing LLM response
1474 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1475
Sean McCullough885a16a2025-04-30 02:49:25 +00001476 if cancelled {
1477 return false, nil
1478 }
1479
1480 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001481}
1482
1483func (a *Agent) overBudget(ctx context.Context) error {
1484 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001485 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001486 m := budgetMessage(err)
1487 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001488 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001489 a.convo.ResetBudget(a.originalBudget)
1490 return err
1491 }
1492 return nil
1493}
1494
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001495func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001496 // Collect all text content
1497 var allText strings.Builder
1498 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001499 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001500 if allText.Len() > 0 {
1501 allText.WriteString("\n\n")
1502 }
1503 allText.WriteString(content.Text)
1504 }
1505 }
1506 return allText.String()
1507}
1508
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001509func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001510 a.mu.Lock()
1511 defer a.mu.Unlock()
1512 return a.convo.CumulativeUsage()
1513}
1514
Earl Lee2e463fb2025-04-17 11:22:22 -07001515// Diff returns a unified diff of changes made since the agent was instantiated.
1516func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001517 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001518 return "", fmt.Errorf("no initial commit reference available")
1519 }
1520
1521 // Find the repository root
1522 ctx := context.Background()
1523
1524 // If a specific commit hash is provided, show just that commit's changes
1525 if commit != nil && *commit != "" {
1526 // Validate that the commit looks like a valid git SHA
1527 if !isValidGitSHA(*commit) {
1528 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1529 }
1530
1531 // Get the diff for just this commit
1532 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1533 cmd.Dir = a.repoRoot
1534 output, err := cmd.CombinedOutput()
1535 if err != nil {
1536 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1537 }
1538 return string(output), nil
1539 }
1540
1541 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001542 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001543 cmd.Dir = a.repoRoot
1544 output, err := cmd.CombinedOutput()
1545 if err != nil {
1546 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1547 }
1548
1549 return string(output), nil
1550}
1551
Philip Zeyliger49edc922025-05-14 09:45:45 -07001552// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1553// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1554func (a *Agent) SketchGitBaseRef() string {
1555 if a.IsInContainer() {
1556 return "sketch-base"
1557 } else {
1558 return "sketch-base-" + a.SessionID()
1559 }
1560}
1561
1562// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1563func (a *Agent) SketchGitBase() string {
1564 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1565 cmd.Dir = a.repoRoot
1566 output, err := cmd.CombinedOutput()
1567 if err != nil {
1568 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1569 return "HEAD"
1570 }
1571 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001572}
1573
Pokey Rule7a113622025-05-12 10:58:45 +01001574// removeGitHooks removes the Git hooks directory from the repository
1575func removeGitHooks(_ context.Context, repoPath string) error {
1576 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1577
1578 // Check if hooks directory exists
1579 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1580 // Directory doesn't exist, nothing to do
1581 return nil
1582 }
1583
1584 // Remove the hooks directory
1585 err := os.RemoveAll(hooksDir)
1586 if err != nil {
1587 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1588 }
1589
1590 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001591 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001592 if err != nil {
1593 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1594 }
1595
1596 return nil
1597}
1598
Earl Lee2e463fb2025-04-17 11:22:22 -07001599// handleGitCommits() highlights new commits to the user. When running
1600// under docker, new HEADs are pushed to a branch according to the title.
1601func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1602 if a.repoRoot == "" {
1603 return nil, nil
1604 }
1605
1606 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1607 if err != nil {
1608 return nil, err
1609 }
1610 if head == a.lastHEAD {
1611 return nil, nil // nothing to do
1612 }
1613 defer func() {
1614 a.lastHEAD = head
1615 }()
1616
1617 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1618 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1619 // to the last 100 commits.
1620 var commits []*GitCommit
1621
1622 // Get commits since the initial commit
1623 // Format: <hash>\0<subject>\0<body>\0
1624 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1625 // Limit to 100 commits to avoid overwhelming the user
Philip Zeyliger49edc922025-05-14 09:45:45 -07001626 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.SketchGitBaseRef(), head)
Earl Lee2e463fb2025-04-17 11:22:22 -07001627 cmd.Dir = a.repoRoot
1628 output, err := cmd.Output()
1629 if err != nil {
1630 return nil, fmt.Errorf("failed to get git log: %w", err)
1631 }
1632
1633 // Parse git log output and filter out already seen commits
1634 parsedCommits := parseGitLog(string(output))
1635
1636 var headCommit *GitCommit
1637
1638 // Filter out commits we've already seen
1639 for _, commit := range parsedCommits {
1640 if commit.Hash == head {
1641 headCommit = &commit
1642 }
1643
1644 // Skip if we've seen this commit before. If our head has changed, always include that.
1645 if a.seenCommits[commit.Hash] && commit.Hash != head {
1646 continue
1647 }
1648
1649 // Mark this commit as seen
1650 a.seenCommits[commit.Hash] = true
1651
1652 // Add to our list of new commits
1653 commits = append(commits, &commit)
1654 }
1655
1656 if a.gitRemoteAddr != "" {
1657 if headCommit == nil {
1658 // I think this can only happen if we have a bug or if there's a race.
1659 headCommit = &GitCommit{}
1660 headCommit.Hash = head
1661 headCommit.Subject = "unknown"
1662 commits = append(commits, headCommit)
1663 }
1664
Philip Zeyliger113e2052025-05-09 21:59:40 +00001665 originalBranch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
1666 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001667
1668 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1669 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1670 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001671
1672 // Try up to 10 times with different branch names if the branch is checked out on the remote
1673 var out []byte
1674 var err error
1675 for retries := range 10 {
1676 if retries > 0 {
1677 // Add a numeric suffix to the branch name
1678 branch = fmt.Sprintf("%s%d", originalBranch, retries)
1679 }
1680
1681 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1682 cmd.Dir = a.workingDir
1683 out, err = cmd.CombinedOutput()
1684
1685 if err == nil {
1686 // Success! Break out of the retry loop
1687 break
1688 }
1689
1690 // Check if this is the "refusing to update checked out branch" error
1691 if !strings.Contains(string(out), "refusing to update checked out branch") {
1692 // This is a different error, so don't retry
1693 break
1694 }
1695
1696 // If we're on the last retry, we'll report the error
1697 if retries == 9 {
1698 break
1699 }
1700 }
1701
1702 if err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001703 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1704 } else {
1705 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001706 // Update the agent's branch name if we ended up using a different one
1707 if branch != originalBranch {
1708 a.branchName = branch
1709 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001710 }
1711 }
1712
1713 // If we found new commits, create a message
1714 if len(commits) > 0 {
1715 msg := AgentMessage{
1716 Type: CommitMessageType,
1717 Timestamp: time.Now(),
1718 Commits: commits,
1719 }
1720 a.pushToOutbox(ctx, msg)
1721 }
1722 return commits, nil
1723}
1724
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001725func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001726 return strings.Map(func(r rune) rune {
1727 // lowercase
1728 if r >= 'A' && r <= 'Z' {
1729 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001730 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001731 // replace spaces with dashes
1732 if r == ' ' {
1733 return '-'
1734 }
1735 // allow alphanumerics and dashes
1736 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1737 return r
1738 }
1739 return -1
1740 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001741}
1742
1743// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1744// and returns an array of GitCommit structs.
1745func parseGitLog(output string) []GitCommit {
1746 var commits []GitCommit
1747
1748 // No output means no commits
1749 if len(output) == 0 {
1750 return commits
1751 }
1752
1753 // Split by NULL byte
1754 parts := strings.Split(output, "\x00")
1755
1756 // Process in triplets (hash, subject, body)
1757 for i := 0; i < len(parts); i++ {
1758 // Skip empty parts
1759 if parts[i] == "" {
1760 continue
1761 }
1762
1763 // This should be a hash
1764 hash := strings.TrimSpace(parts[i])
1765
1766 // Make sure we have at least a subject part available
1767 if i+1 >= len(parts) {
1768 break // No more parts available
1769 }
1770
1771 // Get the subject
1772 subject := strings.TrimSpace(parts[i+1])
1773
1774 // Get the body if available
1775 body := ""
1776 if i+2 < len(parts) {
1777 body = strings.TrimSpace(parts[i+2])
1778 }
1779
1780 // Skip to the next triplet
1781 i += 2
1782
1783 commits = append(commits, GitCommit{
1784 Hash: hash,
1785 Subject: subject,
1786 Body: body,
1787 })
1788 }
1789
1790 return commits
1791}
1792
1793func repoRoot(ctx context.Context, dir string) (string, error) {
1794 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1795 stderr := new(strings.Builder)
1796 cmd.Stderr = stderr
1797 cmd.Dir = dir
1798 out, err := cmd.Output()
1799 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001800 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07001801 }
1802 return strings.TrimSpace(string(out)), nil
1803}
1804
1805func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1806 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1807 stderr := new(strings.Builder)
1808 cmd.Stderr = stderr
1809 cmd.Dir = dir
1810 out, err := cmd.Output()
1811 if err != nil {
1812 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1813 }
1814 // TODO: validate that out is valid hex
1815 return strings.TrimSpace(string(out)), nil
1816}
1817
1818// isValidGitSHA validates if a string looks like a valid git SHA hash.
1819// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1820func isValidGitSHA(sha string) bool {
1821 // Git SHA must be a hexadecimal string with at least 4 characters
1822 if len(sha) < 4 || len(sha) > 40 {
1823 return false
1824 }
1825
1826 // Check if the string only contains hexadecimal characters
1827 for _, char := range sha {
1828 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1829 return false
1830 }
1831 }
1832
1833 return true
1834}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001835
1836// getGitOrigin returns the URL of the git remote 'origin' if it exists
1837func getGitOrigin(ctx context.Context, dir string) string {
1838 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1839 cmd.Dir = dir
1840 stderr := new(strings.Builder)
1841 cmd.Stderr = stderr
1842 out, err := cmd.Output()
1843 if err != nil {
1844 return ""
1845 }
1846 return strings.TrimSpace(string(out))
1847}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001848
1849func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1850 cmd := exec.CommandContext(ctx, "git", "stash")
1851 cmd.Dir = workingDir
1852 if out, err := cmd.CombinedOutput(); err != nil {
1853 return fmt.Errorf("git stash: %s: %v", out, err)
1854 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001855 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001856 cmd.Dir = workingDir
1857 if out, err := cmd.CombinedOutput(); err != nil {
1858 return fmt.Errorf("git fetch: %s: %w", out, err)
1859 }
1860 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1861 cmd.Dir = workingDir
1862 if out, err := cmd.CombinedOutput(); err != nil {
1863 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1864 }
1865 a.lastHEAD = revision
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001866 return nil
1867}
1868
1869func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1870 a.mu.Lock()
1871 a.title = ""
1872 a.firstMessageIndex = len(a.history)
1873 a.convo = a.initConvo()
1874 gitReset := func() error {
1875 if a.config.InDocker && rev != "" {
1876 err := a.initGitRevision(ctx, a.workingDir, rev)
1877 if err != nil {
1878 return err
1879 }
1880 } else if !a.config.InDocker && rev != "" {
1881 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1882 }
1883 return nil
1884 }
1885 err := gitReset()
1886 a.mu.Unlock()
1887 if err != nil {
1888 a.pushToOutbox(a.config.Context, errorMessage(err))
1889 }
1890
1891 a.pushToOutbox(a.config.Context, AgentMessage{
1892 Type: AgentMessageType, Content: "Conversation restarted.",
1893 })
1894 if initialPrompt != "" {
1895 a.UserMessage(ctx, initialPrompt)
1896 }
1897 return nil
1898}
1899
1900func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1901 msg := `The user has requested a suggestion for a re-prompt.
1902
1903 Given the current conversation thus far, suggest a re-prompt that would
1904 capture the instructions and feedback so far, as well as any
1905 research or other information that would be helpful in implementing
1906 the task.
1907
1908 Reply with ONLY the reprompt text.
1909 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001910 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001911 // By doing this in a subconversation, the agent doesn't call tools (because
1912 // there aren't any), and there's not a concurrency risk with on-going other
1913 // outstanding conversations.
1914 convo := a.convo.SubConvoWithHistory()
1915 resp, err := convo.SendMessage(userMessage)
1916 if err != nil {
1917 a.pushToOutbox(ctx, errorMessage(err))
1918 return "", err
1919 }
1920 textContent := collectTextContent(resp)
1921 return textContent, nil
1922}
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001923
1924// systemPromptData contains the data used to render the system prompt template
1925type systemPromptData struct {
1926 EditPrompt string
1927 ClientGOOS string
1928 ClientGOARCH string
1929 WorkingDir string
1930 RepoRoot string
1931 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001932 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001933}
1934
1935// renderSystemPrompt renders the system prompt template.
1936func (a *Agent) renderSystemPrompt() string {
1937 // Determine the appropriate edit prompt based on config
1938 var editPrompt string
1939 if a.config.UseAnthropicEdit {
1940 editPrompt = "Then use the str_replace_editor tool to make those edits. For short complete file replacements, you may use the bash tool with cat and heredoc stdin."
1941 } else {
1942 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1943 }
1944
1945 data := systemPromptData{
1946 EditPrompt: editPrompt,
1947 ClientGOOS: a.config.ClientGOOS,
1948 ClientGOARCH: a.config.ClientGOARCH,
1949 WorkingDir: a.workingDir,
1950 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07001951 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001952 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001953 }
1954
1955 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1956 if err != nil {
1957 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1958 }
1959 buf := new(strings.Builder)
1960 err = tmpl.Execute(buf, data)
1961 if err != nil {
1962 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1963 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001964 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001965 return buf.String()
1966}
Philip Zeyligereab12de2025-05-14 02:35:53 +00001967
1968// StateTransitionIterator provides an iterator over state transitions.
1969type StateTransitionIterator interface {
1970 // Next blocks until a new state transition is available or context is done.
1971 // Returns nil if the context is cancelled.
1972 Next() *StateTransition
1973 // Close removes the listener and cleans up resources.
1974 Close()
1975}
1976
1977// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
1978type StateTransitionIteratorImpl struct {
1979 agent *Agent
1980 ctx context.Context
1981 ch chan StateTransition
1982 unsubscribe func()
1983}
1984
1985// Next blocks until a new state transition is available or the context is cancelled.
1986func (s *StateTransitionIteratorImpl) Next() *StateTransition {
1987 select {
1988 case <-s.ctx.Done():
1989 return nil
1990 case transition, ok := <-s.ch:
1991 if !ok {
1992 return nil
1993 }
1994 transitionCopy := transition
1995 return &transitionCopy
1996 }
1997}
1998
1999// Close removes the listener and cleans up resources.
2000func (s *StateTransitionIteratorImpl) Close() {
2001 if s.unsubscribe != nil {
2002 s.unsubscribe()
2003 s.unsubscribe = nil
2004 }
2005}
2006
2007// NewStateTransitionIterator returns an iterator that receives state transitions.
2008func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2009 a.mu.Lock()
2010 defer a.mu.Unlock()
2011
2012 // Create channel to receive state transitions
2013 ch := make(chan StateTransition, 10)
2014
2015 // Add a listener to the state machine
2016 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2017
2018 return &StateTransitionIteratorImpl{
2019 agent: a,
2020 ctx: ctx,
2021 ch: ch,
2022 unsubscribe: unsubscribe,
2023 }
2024}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002025
2026// setupGitHooks creates or updates git hooks in the specified working directory.
2027func setupGitHooks(workingDir string) error {
2028 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2029
2030 _, err := os.Stat(hooksDir)
2031 if os.IsNotExist(err) {
2032 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2033 }
2034 if err != nil {
2035 return fmt.Errorf("error checking git hooks directory: %w", err)
2036 }
2037
2038 // Define the post-commit hook content
2039 postCommitHook := `#!/bin/bash
2040echo "<post_commit_hook>"
2041echo "Please review this commit message and fix it if it is incorrect."
2042echo "This hook only echos the commit message; it does not modify it."
2043echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2044echo "<last_commit_message>"
2045git log -1 --pretty=%B
2046echo "</last_commit_message>"
2047echo "</post_commit_hook>"
2048`
2049
2050 // Define the prepare-commit-msg hook content
2051 prepareCommitMsgHook := `#!/bin/bash
2052# Add Co-Authored-By and Change-ID trailers to commit messages
2053# Check if these trailers already exist before adding them
2054
2055commit_file="$1"
2056COMMIT_SOURCE="$2"
2057
2058# Skip for merges, squashes, or when using a commit template
2059if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2060 [ "$COMMIT_SOURCE" = "squash" ]; then
2061 exit 0
2062fi
2063
2064commit_msg=$(cat "$commit_file")
2065
2066needs_co_author=true
2067needs_change_id=true
2068
2069# Check if commit message already has Co-Authored-By trailer
2070if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2071 needs_co_author=false
2072fi
2073
2074# Check if commit message already has Change-ID trailer
2075if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2076 needs_change_id=false
2077fi
2078
2079# Only modify if at least one trailer needs to be added
2080if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
2081 # Ensure there's a blank line before trailers
2082 if [ -s "$commit_file" ] && [ "$(tail -1 "$commit_file" | tr -d '\n')" != "" ]; then
2083 echo "" >> "$commit_file"
2084 fi
2085
2086 # Add trailers if needed
2087 if [ "$needs_co_author" = true ]; then
2088 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2089 fi
2090
2091 if [ "$needs_change_id" = true ]; then
2092 change_id=$(openssl rand -hex 8)
2093 echo "Change-ID: s${change_id}k" >> "$commit_file"
2094 fi
2095fi
2096`
2097
2098 // Update or create the post-commit hook
2099 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2100 if err != nil {
2101 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2102 }
2103
2104 // Update or create the prepare-commit-msg hook
2105 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2106 if err != nil {
2107 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2108 }
2109
2110 return nil
2111}
2112
2113// updateOrCreateHook creates a new hook file or updates an existing one
2114// by appending the new content if it doesn't already contain it.
2115func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2116 // Check if the hook already exists
2117 buf, err := os.ReadFile(hookPath)
2118 if os.IsNotExist(err) {
2119 // Hook doesn't exist, create it
2120 err = os.WriteFile(hookPath, []byte(content), 0o755)
2121 if err != nil {
2122 return fmt.Errorf("failed to create hook: %w", err)
2123 }
2124 return nil
2125 }
2126 if err != nil {
2127 return fmt.Errorf("error reading existing hook: %w", err)
2128 }
2129
2130 // Hook exists, check if our content is already in it by looking for a distinctive line
2131 code := string(buf)
2132 if strings.Contains(code, distinctiveLine) {
2133 // Already contains our content, nothing to do
2134 return nil
2135 }
2136
2137 // Append our content to the existing hook
2138 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2139 if err != nil {
2140 return fmt.Errorf("failed to open hook for appending: %w", err)
2141 }
2142 defer f.Close()
2143
2144 // Ensure there's a newline at the end of the existing content if needed
2145 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2146 _, err = f.WriteString("\n")
2147 if err != nil {
2148 return fmt.Errorf("failed to add newline to hook: %w", err)
2149 }
2150 }
2151
2152 // Add a separator before our content
2153 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2154 if err != nil {
2155 return fmt.Errorf("failed to append to hook: %w", err)
2156 }
2157
2158 return nil
2159}