blob: a96c35eda9e09834958ba7c06192fd6733eeed6e [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 Snydere2518e52025-04-29 11:13:40 -070028 "sketch.dev/experiment"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070029 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070030 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070031 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070032)
33
34const (
35 userCancelMessage = "user requested agent to stop handling responses"
36)
37
Philip Zeyligerb7c58752025-05-01 10:10:17 -070038type MessageIterator interface {
39 // Next blocks until the next message is available. It may
40 // return nil if the underlying iterator context is done.
41 Next() *AgentMessage
42 Close()
43}
44
Earl Lee2e463fb2025-04-17 11:22:22 -070045type CodingAgent interface {
46 // Init initializes an agent inside a docker container.
47 Init(AgentInit) error
48
49 // Ready returns a channel closed after Init successfully called.
50 Ready() <-chan struct{}
51
52 // URL reports the HTTP URL of this agent.
53 URL() string
54
55 // UserMessage enqueues a message to the agent and returns immediately.
56 UserMessage(ctx context.Context, msg string)
57
Philip Zeyligerb7c58752025-05-01 10:10:17 -070058 // Returns an iterator that finishes when the context is done and
59 // starts with the given message index.
60 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070061
Philip Zeyligereab12de2025-05-14 02:35:53 +000062 // Returns an iterator that notifies of state transitions until the context is done.
63 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
64
Earl Lee2e463fb2025-04-17 11:22:22 -070065 // Loop begins the agent loop returns only when ctx is cancelled.
66 Loop(ctx context.Context)
67
Sean McCulloughedc88dc2025-04-30 02:55:01 +000068 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070069
70 CancelToolUse(toolUseID string, cause error) error
71
72 // Returns a subset of the agent's message history.
73 Messages(start int, end int) []AgentMessage
74
75 // Returns the current number of messages in the history
76 MessageCount() int
77
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070078 TotalUsage() conversation.CumulativeUsage
79 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070080
Earl Lee2e463fb2025-04-17 11:22:22 -070081 WorkingDir() string
82
83 // Diff returns a unified diff of changes made since the agent was instantiated.
84 // If commit is non-nil, it shows the diff for just that specific commit.
85 Diff(commit *string) (string, error)
86
Philip Zeyliger49edc922025-05-14 09:45:45 -070087 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
88 // starts out as the commit where sketch started, but a user can move it if need
89 // be, for example in the case of a rebase. It is stored as a git tag.
90 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070091
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000092 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
93 // (Typically, this is "sketch-base")
94 SketchGitBaseRef() string
95
Earl Lee2e463fb2025-04-17 11:22:22 -070096 // Title returns the current title of the conversation.
97 Title() string
98
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000099 // BranchName returns the git branch name for the conversation.
100 BranchName() string
101
Earl Lee2e463fb2025-04-17 11:22:22 -0700102 // OS returns the operating system of the client.
103 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000104
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000105 // SessionID returns the unique session identifier.
106 SessionID() string
107
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000108 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
109 OutstandingLLMCallCount() int
110
111 // OutstandingToolCalls returns the names of outstanding tool calls.
112 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000113 OutsideOS() string
114 OutsideHostname() string
115 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000116 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000117 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
118 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700119
120 // RestartConversation resets the conversation history
121 RestartConversation(ctx context.Context, rev string, initialPrompt string) error
122 // SuggestReprompt suggests a re-prompt based on the current conversation.
123 SuggestReprompt(ctx context.Context) (string, error)
124 // IsInContainer returns true if the agent is running in a container
125 IsInContainer() bool
126 // FirstMessageIndex returns the index of the first message in the current conversation
127 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700128
129 CurrentStateName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700130}
131
132type CodingAgentMessageType string
133
134const (
135 UserMessageType CodingAgentMessageType = "user"
136 AgentMessageType CodingAgentMessageType = "agent"
137 ErrorMessageType CodingAgentMessageType = "error"
138 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
139 ToolUseMessageType CodingAgentMessageType = "tool"
140 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
141 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
142
143 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
144)
145
146type AgentMessage struct {
147 Type CodingAgentMessageType `json:"type"`
148 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
149 EndOfTurn bool `json:"end_of_turn"`
150
151 Content string `json:"content"`
152 ToolName string `json:"tool_name,omitempty"`
153 ToolInput string `json:"input,omitempty"`
154 ToolResult string `json:"tool_result,omitempty"`
155 ToolError bool `json:"tool_error,omitempty"`
156 ToolCallId string `json:"tool_call_id,omitempty"`
157
158 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
159 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
160
Sean McCulloughd9f13372025-04-21 15:08:49 -0700161 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
162 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
163
Earl Lee2e463fb2025-04-17 11:22:22 -0700164 // Commits is a list of git commits for a commit message
165 Commits []*GitCommit `json:"commits,omitempty"`
166
167 Timestamp time.Time `json:"timestamp"`
168 ConversationID string `json:"conversation_id"`
169 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700170 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700171
172 // Message timing information
173 StartTime *time.Time `json:"start_time,omitempty"`
174 EndTime *time.Time `json:"end_time,omitempty"`
175 Elapsed *time.Duration `json:"elapsed,omitempty"`
176
177 // Turn duration - the time taken for a complete agent turn
178 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
179
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000180 // HideOutput indicates that this message should not be rendered in the UI.
181 // This is useful for subconversations that generate output that shouldn't be shown to the user.
182 HideOutput bool `json:"hide_output,omitempty"`
183
Earl Lee2e463fb2025-04-17 11:22:22 -0700184 Idx int `json:"idx"`
185}
186
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000187// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700188func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700189 if convo == nil {
190 m.ConversationID = ""
191 m.ParentConversationID = nil
192 return
193 }
194 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000195 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700196 if convo.Parent != nil {
197 m.ParentConversationID = &convo.Parent.ID
198 }
199}
200
Earl Lee2e463fb2025-04-17 11:22:22 -0700201// GitCommit represents a single git commit for a commit message
202type GitCommit struct {
203 Hash string `json:"hash"` // Full commit hash
204 Subject string `json:"subject"` // Commit subject line
205 Body string `json:"body"` // Full commit message body
206 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
207}
208
209// ToolCall represents a single tool call within an agent message
210type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700211 Name string `json:"name"`
212 Input string `json:"input"`
213 ToolCallId string `json:"tool_call_id"`
214 ResultMessage *AgentMessage `json:"result_message,omitempty"`
215 Args string `json:"args,omitempty"`
216 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700217}
218
219func (a *AgentMessage) Attr() slog.Attr {
220 var attrs []any = []any{
221 slog.String("type", string(a.Type)),
222 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700223 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700224 if a.EndOfTurn {
225 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
226 }
227 if a.Content != "" {
228 attrs = append(attrs, slog.String("content", a.Content))
229 }
230 if a.ToolName != "" {
231 attrs = append(attrs, slog.String("tool_name", a.ToolName))
232 }
233 if a.ToolInput != "" {
234 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
235 }
236 if a.Elapsed != nil {
237 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
238 }
239 if a.TurnDuration != nil {
240 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
241 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700242 if len(a.ToolResult) > 0 {
243 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700244 }
245 if a.ToolError {
246 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
247 }
248 if len(a.ToolCalls) > 0 {
249 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
250 for i, tc := range a.ToolCalls {
251 toolCallAttrs = append(toolCallAttrs, slog.Group(
252 fmt.Sprintf("tool_call_%d", i),
253 slog.String("name", tc.Name),
254 slog.String("input", tc.Input),
255 ))
256 }
257 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
258 }
259 if a.ConversationID != "" {
260 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
261 }
262 if a.ParentConversationID != nil {
263 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
264 }
265 if a.Usage != nil && !a.Usage.IsZero() {
266 attrs = append(attrs, a.Usage.Attr())
267 }
268 // TODO: timestamp, convo ids, idx?
269 return slog.Group("agent_message", attrs...)
270}
271
272func errorMessage(err error) AgentMessage {
273 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
274 if os.Getenv(("DEBUG")) == "1" {
275 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
276 }
277
278 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
279}
280
281func budgetMessage(err error) AgentMessage {
282 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
283}
284
285// ConvoInterface defines the interface for conversation interactions
286type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700287 CumulativeUsage() conversation.CumulativeUsage
288 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700289 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700290 SendMessage(message llm.Message) (*llm.Response, error)
291 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700292 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000293 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700294 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700295 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700296 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700297}
298
299type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700300 convo ConvoInterface
301 config AgentConfig // config for this agent
302 workingDir string
303 repoRoot string // workingDir may be a subdir of repoRoot
304 url string
305 firstMessageIndex int // index of the first message in the current conversation
306 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700307 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000308 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700309 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000310 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700311 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700312 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700313 title string
314 branchName string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000315 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700316 // State machine to track agent state
317 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000318 // Outside information
319 outsideHostname string
320 outsideOS string
321 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000322 // URL of the git remote 'origin' if it exists
323 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700324
325 // Time when the current turn started (reset at the beginning of InnerLoop)
326 startOfTurn time.Time
327
328 // Inbox - for messages from the user to the agent.
329 // sent on by UserMessage
330 // . e.g. when user types into the chat textarea
331 // read from by GatherMessages
332 inbox chan string
333
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000334 // protects cancelTurn
335 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700336 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000337 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700338
339 // protects following
340 mu sync.Mutex
341
342 // Stores all messages for this agent
343 history []AgentMessage
344
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700345 // Iterators add themselves here when they're ready to be notified of new messages.
346 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700347
348 // Track git commits we've already seen (by hash)
349 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000350
351 // Track outstanding LLM call IDs
352 outstandingLLMCalls map[string]struct{}
353
354 // Track outstanding tool calls by ID with their names
355 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700356}
357
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700358// NewIterator implements CodingAgent.
359func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
360 a.mu.Lock()
361 defer a.mu.Unlock()
362
363 return &MessageIteratorImpl{
364 agent: a,
365 ctx: ctx,
366 nextMessageIdx: nextMessageIdx,
367 ch: make(chan *AgentMessage, 100),
368 }
369}
370
371type MessageIteratorImpl struct {
372 agent *Agent
373 ctx context.Context
374 nextMessageIdx int
375 ch chan *AgentMessage
376 subscribed bool
377}
378
379func (m *MessageIteratorImpl) Close() {
380 m.agent.mu.Lock()
381 defer m.agent.mu.Unlock()
382 // Delete ourselves from the subscribers list
383 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
384 return x == m.ch
385 })
386 close(m.ch)
387}
388
389func (m *MessageIteratorImpl) Next() *AgentMessage {
390 // We avoid subscription at creation to let ourselves catch up to "current state"
391 // before subscribing.
392 if !m.subscribed {
393 m.agent.mu.Lock()
394 if m.nextMessageIdx < len(m.agent.history) {
395 msg := &m.agent.history[m.nextMessageIdx]
396 m.nextMessageIdx++
397 m.agent.mu.Unlock()
398 return msg
399 }
400 // The next message doesn't exist yet, so let's subscribe
401 m.agent.subscribers = append(m.agent.subscribers, m.ch)
402 m.subscribed = true
403 m.agent.mu.Unlock()
404 }
405
406 for {
407 select {
408 case <-m.ctx.Done():
409 m.agent.mu.Lock()
410 // Delete ourselves from the subscribers list
411 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
412 return x == m.ch
413 })
414 m.subscribed = false
415 m.agent.mu.Unlock()
416 return nil
417 case msg, ok := <-m.ch:
418 if !ok {
419 // Close may have been called
420 return nil
421 }
422 if msg.Idx == m.nextMessageIdx {
423 m.nextMessageIdx++
424 return msg
425 }
426 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
427 panic("out of order message")
428 }
429 }
430}
431
Sean McCulloughd9d45812025-04-30 16:53:41 -0700432// Assert that Agent satisfies the CodingAgent interface.
433var _ CodingAgent = &Agent{}
434
435// StateName implements CodingAgent.
436func (a *Agent) CurrentStateName() string {
437 if a.stateMachine == nil {
438 return ""
439 }
440 return a.stateMachine.currentState.String()
441}
442
Earl Lee2e463fb2025-04-17 11:22:22 -0700443func (a *Agent) URL() string { return a.url }
444
445// Title returns the current title of the conversation.
446// If no title has been set, returns an empty string.
447func (a *Agent) Title() string {
448 a.mu.Lock()
449 defer a.mu.Unlock()
450 return a.title
451}
452
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000453// BranchName returns the git branch name for the conversation.
454func (a *Agent) BranchName() string {
455 a.mu.Lock()
456 defer a.mu.Unlock()
457 return a.branchName
458}
459
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000460// OutstandingLLMCallCount returns the number of outstanding LLM calls.
461func (a *Agent) OutstandingLLMCallCount() int {
462 a.mu.Lock()
463 defer a.mu.Unlock()
464 return len(a.outstandingLLMCalls)
465}
466
467// OutstandingToolCalls returns the names of outstanding tool calls.
468func (a *Agent) OutstandingToolCalls() []string {
469 a.mu.Lock()
470 defer a.mu.Unlock()
471
472 tools := make([]string, 0, len(a.outstandingToolCalls))
473 for _, toolName := range a.outstandingToolCalls {
474 tools = append(tools, toolName)
475 }
476 return tools
477}
478
Earl Lee2e463fb2025-04-17 11:22:22 -0700479// OS returns the operating system of the client.
480func (a *Agent) OS() string {
481 return a.config.ClientGOOS
482}
483
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000484func (a *Agent) SessionID() string {
485 return a.config.SessionID
486}
487
Philip Zeyliger18532b22025-04-23 21:11:46 +0000488// OutsideOS returns the operating system of the outside system.
489func (a *Agent) OutsideOS() string {
490 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000491}
492
Philip Zeyliger18532b22025-04-23 21:11:46 +0000493// OutsideHostname returns the hostname of the outside system.
494func (a *Agent) OutsideHostname() string {
495 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000496}
497
Philip Zeyliger18532b22025-04-23 21:11:46 +0000498// OutsideWorkingDir returns the working directory on the outside system.
499func (a *Agent) OutsideWorkingDir() string {
500 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000501}
502
503// GitOrigin returns the URL of the git remote 'origin' if it exists.
504func (a *Agent) GitOrigin() string {
505 return a.gitOrigin
506}
507
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000508func (a *Agent) OpenBrowser(url string) {
509 if !a.IsInContainer() {
510 browser.Open(url)
511 return
512 }
513 // We're in Docker, need to send a request to the Git server
514 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700515 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000516 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700517 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000518 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700519 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000520 return
521 }
522 defer resp.Body.Close()
523 if resp.StatusCode == http.StatusOK {
524 return
525 }
526 body, _ := io.ReadAll(resp.Body)
527 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
528}
529
Sean McCullough96b60dd2025-04-30 09:49:10 -0700530// CurrentState returns the current state of the agent's state machine.
531func (a *Agent) CurrentState() State {
532 return a.stateMachine.CurrentState()
533}
534
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700535func (a *Agent) IsInContainer() bool {
536 return a.config.InDocker
537}
538
539func (a *Agent) FirstMessageIndex() int {
540 a.mu.Lock()
541 defer a.mu.Unlock()
542 return a.firstMessageIndex
543}
544
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000545// SetTitle sets the title of the conversation.
546func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700547 a.mu.Lock()
548 defer a.mu.Unlock()
549 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000550}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700551
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000552// SetBranch sets the branch name of the conversation.
553func (a *Agent) SetBranch(branchName string) {
554 a.mu.Lock()
555 defer a.mu.Unlock()
556 a.branchName = branchName
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000557 convo, ok := a.convo.(*conversation.Convo)
558 if ok {
559 convo.ExtraData["branch"] = branchName
560 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700561}
562
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000563// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700564func (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 +0000565 // Track the tool call
566 a.mu.Lock()
567 a.outstandingToolCalls[id] = toolName
568 a.mu.Unlock()
569}
570
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700571// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
572// If there's only one element in the array and it's a text type, it returns that text directly.
573// It also processes nested ToolResult arrays recursively.
574func contentToString(contents []llm.Content) string {
575 if len(contents) == 0 {
576 return ""
577 }
578
579 // If there's only one element and it's a text type, return it directly
580 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
581 return contents[0].Text
582 }
583
584 // Otherwise, concatenate all text content
585 var result strings.Builder
586 for _, content := range contents {
587 if content.Type == llm.ContentTypeText {
588 result.WriteString(content.Text)
589 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
590 // Recursively process nested tool results
591 result.WriteString(contentToString(content.ToolResult))
592 }
593 }
594
595 return result.String()
596}
597
Earl Lee2e463fb2025-04-17 11:22:22 -0700598// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700599func (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 +0000600 // Remove the tool call from outstanding calls
601 a.mu.Lock()
602 delete(a.outstandingToolCalls, toolID)
603 a.mu.Unlock()
604
Earl Lee2e463fb2025-04-17 11:22:22 -0700605 m := AgentMessage{
606 Type: ToolUseMessageType,
607 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700608 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700609 ToolError: content.ToolError,
610 ToolName: toolName,
611 ToolInput: string(toolInput),
612 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700613 StartTime: content.ToolUseStartTime,
614 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700615 }
616
617 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700618 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
619 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700620 m.Elapsed = &elapsed
621 }
622
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700623 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700624 a.pushToOutbox(ctx, m)
625}
626
627// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700628func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000629 a.mu.Lock()
630 defer a.mu.Unlock()
631 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700632 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
633}
634
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700635// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700636// that need to be displayed (as well as tool calls that we send along when
637// they're done). (It would be reasonable to also mention tool calls when they're
638// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700639func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000640 // Remove the LLM call from outstanding calls
641 a.mu.Lock()
642 delete(a.outstandingLLMCalls, id)
643 a.mu.Unlock()
644
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700645 if resp == nil {
646 // LLM API call failed
647 m := AgentMessage{
648 Type: ErrorMessageType,
649 Content: "API call failed, type 'continue' to try again",
650 }
651 m.SetConvo(convo)
652 a.pushToOutbox(ctx, m)
653 return
654 }
655
Earl Lee2e463fb2025-04-17 11:22:22 -0700656 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700657 if convo.Parent == nil { // subconvos never end the turn
658 switch resp.StopReason {
659 case llm.StopReasonToolUse:
660 // Check whether any of the tool calls are for tools that should end the turn
661 ToolSearch:
662 for _, part := range resp.Content {
663 if part.Type != llm.ContentTypeToolUse {
664 continue
665 }
Sean McCullough021557a2025-05-05 23:20:53 +0000666 // Find the tool by name
667 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700668 if tool.Name == part.ToolName {
669 endOfTurn = tool.EndsTurn
670 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000671 }
672 }
Sean McCullough021557a2025-05-05 23:20:53 +0000673 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700674 default:
675 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000676 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700677 }
678 m := AgentMessage{
679 Type: AgentMessageType,
680 Content: collectTextContent(resp),
681 EndOfTurn: endOfTurn,
682 Usage: &resp.Usage,
683 StartTime: resp.StartTime,
684 EndTime: resp.EndTime,
685 }
686
687 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700688 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700689 var toolCalls []ToolCall
690 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700691 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700692 toolCalls = append(toolCalls, ToolCall{
693 Name: part.ToolName,
694 Input: string(part.ToolInput),
695 ToolCallId: part.ID,
696 })
697 }
698 }
699 m.ToolCalls = toolCalls
700 }
701
702 // Calculate the elapsed time if both start and end times are set
703 if resp.StartTime != nil && resp.EndTime != nil {
704 elapsed := resp.EndTime.Sub(*resp.StartTime)
705 m.Elapsed = &elapsed
706 }
707
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700708 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700709 a.pushToOutbox(ctx, m)
710}
711
712// WorkingDir implements CodingAgent.
713func (a *Agent) WorkingDir() string {
714 return a.workingDir
715}
716
717// MessageCount implements CodingAgent.
718func (a *Agent) MessageCount() int {
719 a.mu.Lock()
720 defer a.mu.Unlock()
721 return len(a.history)
722}
723
724// Messages implements CodingAgent.
725func (a *Agent) Messages(start int, end int) []AgentMessage {
726 a.mu.Lock()
727 defer a.mu.Unlock()
728 return slices.Clone(a.history[start:end])
729}
730
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700731func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700732 return a.originalBudget
733}
734
735// AgentConfig contains configuration for creating a new Agent.
736type AgentConfig struct {
737 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700738 Service llm.Service
739 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700740 GitUsername string
741 GitEmail string
742 SessionID string
743 ClientGOOS string
744 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700745 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700746 UseAnthropicEdit bool
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000747 OneShot bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000748 // Outside information
749 OutsideHostname string
750 OutsideOS string
751 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700752}
753
754// NewAgent creates a new Agent.
755// It is not usable until Init() is called.
756func NewAgent(config AgentConfig) *Agent {
757 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000758 config: config,
759 ready: make(chan struct{}),
760 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700761 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000762 startedAt: time.Now(),
763 originalBudget: config.Budget,
764 seenCommits: make(map[string]bool),
765 outsideHostname: config.OutsideHostname,
766 outsideOS: config.OutsideOS,
767 outsideWorkingDir: config.OutsideWorkingDir,
768 outstandingLLMCalls: make(map[string]struct{}),
769 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700770 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700771 }
772 return agent
773}
774
775type AgentInit struct {
776 WorkingDir string
777 NoGit bool // only for testing
778
779 InDocker bool
780 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000781 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700782 GitRemoteAddr string
783 HostAddr string
784}
785
786func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700787 if a.convo != nil {
788 return fmt.Errorf("Agent.Init: already initialized")
789 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700790 ctx := a.config.Context
791 if ini.InDocker {
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +0000792 if err := setupGitHooks(ini.WorkingDir); err != nil {
793 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
794 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700795 cmd := exec.CommandContext(ctx, "git", "stash")
796 cmd.Dir = ini.WorkingDir
797 if out, err := cmd.CombinedOutput(); err != nil {
798 return fmt.Errorf("git stash: %s: %v", out, err)
799 }
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700800 // sketch-host is a git repo hosted by "outtie sketch". When it notices a 'git fetch',
801 // it runs "git fetch" underneath the covers to get its latest commits. By configuring
802 // an additional remote.sketch-host.fetch, we make "origin/main" on innie sketch look like
803 // origin/main on outtie sketch, which should make it easier to rebase.
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700804 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
805 cmd.Dir = ini.WorkingDir
806 if out, err := cmd.CombinedOutput(); err != nil {
807 return fmt.Errorf("git remote add: %s: %v", out, err)
808 }
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700809 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.sketch-host.fetch",
810 "+refs/heads/feature/*:refs/remotes/origin/feature/*")
811 cmd.Dir = ini.WorkingDir
812 if out, err := cmd.CombinedOutput(); err != nil {
813 return fmt.Errorf("git config --add: %s: %v", out, err)
814 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000815 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700816 cmd.Dir = ini.WorkingDir
817 if out, err := cmd.CombinedOutput(); err != nil {
818 return fmt.Errorf("git fetch: %s: %w", out, err)
819 }
820 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
821 cmd.Dir = ini.WorkingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100822 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
823 // Remove git hooks if they exist and retry
824 // Only try removing hooks if we haven't already removed them during fetch
825 hookPath := filepath.Join(ini.WorkingDir, ".git", "hooks")
826 if _, statErr := os.Stat(hookPath); statErr == nil {
827 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
828 slog.String("error", err.Error()),
829 slog.String("output", string(checkoutOut)))
830 if removeErr := removeGitHooks(ctx, ini.WorkingDir); removeErr != nil {
831 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
832 }
833
834 // Retry the checkout operation
835 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
836 cmd.Dir = ini.WorkingDir
837 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
838 return fmt.Errorf("git checkout %s failed even after removing hooks: %s: %w", ini.Commit, retryOut, retryErr)
839 }
840 } else {
841 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, checkoutOut, err)
842 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700843 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700844 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000845 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700846 if ini.HostAddr != "" {
847 a.url = "http://" + ini.HostAddr
848 }
849 }
850 a.workingDir = ini.WorkingDir
851
852 if !ini.NoGit {
853 repoRoot, err := repoRoot(ctx, a.workingDir)
854 if err != nil {
855 return fmt.Errorf("repoRoot: %w", err)
856 }
857 a.repoRoot = repoRoot
858
Earl Lee2e463fb2025-04-17 11:22:22 -0700859 if err != nil {
860 return fmt.Errorf("resolveRef: %w", err)
861 }
Philip Zeyliger49edc922025-05-14 09:45:45 -0700862
863 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
864 cmd.Dir = repoRoot
865 if out, err := cmd.CombinedOutput(); err != nil {
866 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
867 }
868 a.lastHEAD = ini.Commit
Earl Lee2e463fb2025-04-17 11:22:22 -0700869
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +0000870 slog.Info("running codebase analysis")
871 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
872 if err != nil {
873 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000874 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +0000875 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000876
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000877 llmCodeReview := codereview.NoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700878 if experiment.Enabled("llm_review") {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000879 llmCodeReview = codereview.DoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700880 }
Philip Zeyliger49edc922025-05-14 09:45:45 -0700881 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef(), llmCodeReview)
Earl Lee2e463fb2025-04-17 11:22:22 -0700882 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000883 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700884 }
885 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000886
887 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700888 }
Philip Zeyliger49edc922025-05-14 09:45:45 -0700889 a.lastHEAD = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -0700890 a.convo = a.initConvo()
891 close(a.ready)
892 return nil
893}
894
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700895//go:embed agent_system_prompt.txt
896var agentSystemPrompt string
897
Earl Lee2e463fb2025-04-17 11:22:22 -0700898// initConvo initializes the conversation.
899// It must not be called until all agent fields are initialized,
900// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700901func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700902 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700903 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700904 convo.PromptCaching = true
905 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000906 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000907 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -0700908
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000909 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
910 bashPermissionCheck := func(command string) error {
911 // Check if branch name is set
912 a.mu.Lock()
913 branchSet := a.branchName != ""
914 a.mu.Unlock()
915
916 // If branch is set, all commands are allowed
917 if branchSet {
918 return nil
919 }
920
921 // If branch is not set, check if this is a git commit command
922 willCommit, err := bashkit.WillRunGitCommit(command)
923 if err != nil {
924 // If there's an error checking, we should allow the command to proceed
925 return nil
926 }
927
928 // If it's a git commit and branch is not set, return an error
929 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000930 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000931 }
932
933 return nil
934 }
935
936 // Create a custom bash tool with the permission check
937 bashTool := claudetool.NewBashTool(bashPermissionCheck)
938
Earl Lee2e463fb2025-04-17 11:22:22 -0700939 // Register all tools with the conversation
940 // When adding, removing, or modifying tools here, double-check that the termui tool display
941 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000942
943 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -0700944 _, supportsScreenshots := a.config.Service.(*ant.Service)
945 var bTools []*llm.Tool
946 var browserCleanup func()
947
948 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
949 // Add cleanup function to context cancel
950 go func() {
951 <-a.config.Context.Done()
952 browserCleanup()
953 }()
954 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000955
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700956 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000957 bashTool, claudetool.Keyword,
Josh Bleecher Snyder93202652025-05-08 02:05:57 +0000958 claudetool.Think, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -0700959 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000960 }
961
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000962 // One-shot mode is non-interactive, multiple choice requires human response
963 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -0700964 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -0700965 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000966
967 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -0700968 if a.config.UseAnthropicEdit {
969 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
970 } else {
971 convo.Tools = append(convo.Tools, claudetool.Patch)
972 }
973 convo.Listener = a
974 return convo
975}
976
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -0700977var multipleChoiceTool = &llm.Tool{
978 Name: "multiplechoice",
979 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.",
980 EndsTurn: true,
981 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -0700982 "type": "object",
983 "description": "The question and a list of answers you would expect the user to choose from.",
984 "properties": {
985 "question": {
986 "type": "string",
987 "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?'"
988 },
989 "responseOptions": {
990 "type": "array",
991 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
992 "items": {
993 "type": "object",
994 "properties": {
995 "caption": {
996 "type": "string",
997 "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'"
998 },
999 "responseText": {
1000 "type": "string",
1001 "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'"
1002 }
1003 },
1004 "required": ["caption", "responseText"]
1005 }
1006 }
1007 },
1008 "required": ["question", "responseOptions"]
1009}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001010 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1011 // The Run logic for "multiplechoice" tool is a no-op on the server.
1012 // The UI will present a list of options for the user to select from,
1013 // and that's it as far as "executing" the tool_use goes.
1014 // When the user *does* select one of the presented options, that
1015 // responseText gets sent as a chat message on behalf of the user.
1016 return llm.TextContent("end your turn and wait for the user to respond"), nil
1017 },
Sean McCullough485afc62025-04-28 14:28:39 -07001018}
1019
1020type MultipleChoiceOption struct {
1021 Caption string `json:"caption"`
1022 ResponseText string `json:"responseText"`
1023}
1024
1025type MultipleChoiceParams struct {
1026 Question string `json:"question"`
1027 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1028}
1029
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001030// branchExists reports whether branchName exists, either locally or in well-known remotes.
1031func branchExists(dir, branchName string) bool {
1032 refs := []string{
1033 "refs/heads/",
1034 "refs/remotes/origin/",
1035 "refs/remotes/sketch-host/",
1036 }
1037 for _, ref := range refs {
1038 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1039 cmd.Dir = dir
1040 if cmd.Run() == nil { // exit code 0 means branch exists
1041 return true
1042 }
1043 }
1044 return false
1045}
1046
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001047func (a *Agent) titleTool() *llm.Tool {
1048 description := `Sets the conversation title.`
1049 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001050 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001051 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001052 InputSchema: json.RawMessage(`{
1053 "type": "object",
1054 "properties": {
1055 "title": {
1056 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001057 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001058 }
1059 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001060 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001061}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001062 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001063 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001064 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001065 }
1066 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001067 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001068 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001069
1070 // We don't allow changing the title once set to be consistent with the previous behavior
1071 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001072 t := a.Title()
1073 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001074 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001075 }
1076
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001077 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001078 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001079 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001080
1081 a.SetTitle(params.Title)
1082 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001083 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001084 },
1085 }
1086 return titleTool
1087}
1088
1089func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001090 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 +00001091 preCommit := &llm.Tool{
1092 Name: "precommit",
1093 Description: description,
1094 InputSchema: json.RawMessage(`{
1095 "type": "object",
1096 "properties": {
1097 "branch_name": {
1098 "type": "string",
1099 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1100 }
1101 },
1102 "required": ["branch_name"]
1103}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001104 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001105 var params struct {
1106 BranchName string `json:"branch_name"`
1107 }
1108 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001109 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001110 }
1111
1112 b := a.BranchName()
1113 if b != "" {
Josh Bleecher Snyder44d1f1a2025-05-12 19:18:32 -07001114 return nil, fmt.Errorf("branch already set to %s; do not create a new branch", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001115 }
1116
1117 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001118 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001119 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001120 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001121 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001122 }
1123 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001124 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001125 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001126 }
1127
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001128 a.SetBranch(branchName)
Josh Bleecher Snyderf7bebdd2025-05-14 15:22:24 -07001129 response := fmt.Sprintf("switched to branch sketch/%q - DO NOT change branches unless explicitly requested", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001130
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001131 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1132 if err != nil {
1133 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1134 }
1135 if len(styleHint) > 0 {
1136 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001137 }
1138
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001139 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001140 },
1141 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001142 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001143}
1144
1145func (a *Agent) Ready() <-chan struct{} {
1146 return a.ready
1147}
1148
1149func (a *Agent) UserMessage(ctx context.Context, msg string) {
1150 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1151 a.inbox <- msg
1152}
1153
Earl Lee2e463fb2025-04-17 11:22:22 -07001154func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1155 return a.convo.CancelToolUse(toolUseID, cause)
1156}
1157
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001158func (a *Agent) CancelTurn(cause error) {
1159 a.cancelTurnMu.Lock()
1160 defer a.cancelTurnMu.Unlock()
1161 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001162 // Force state transition to cancelled state
1163 ctx := a.config.Context
1164 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001165 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001166 }
1167}
1168
1169func (a *Agent) Loop(ctxOuter context.Context) {
1170 for {
1171 select {
1172 case <-ctxOuter.Done():
1173 return
1174 default:
1175 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001176 a.cancelTurnMu.Lock()
1177 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001178 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001179 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001180 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001181 a.cancelTurn = cancel
1182 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001183 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1184 if err != nil {
1185 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1186 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001187 cancel(nil)
1188 }
1189 }
1190}
1191
1192func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1193 if m.Timestamp.IsZero() {
1194 m.Timestamp = time.Now()
1195 }
1196
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001197 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1198 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1199 m.Content = m.ToolResult
1200 }
1201
Earl Lee2e463fb2025-04-17 11:22:22 -07001202 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1203 if m.EndOfTurn && m.Type == AgentMessageType {
1204 turnDuration := time.Since(a.startOfTurn)
1205 m.TurnDuration = &turnDuration
1206 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1207 }
1208
Earl Lee2e463fb2025-04-17 11:22:22 -07001209 a.mu.Lock()
1210 defer a.mu.Unlock()
1211 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001212 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001213 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001214
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001215 // Notify all subscribers
1216 for _, ch := range a.subscribers {
1217 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001218 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001219}
1220
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001221func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1222 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001223 if block {
1224 select {
1225 case <-ctx.Done():
1226 return m, ctx.Err()
1227 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001228 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001229 }
1230 }
1231 for {
1232 select {
1233 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001234 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001235 default:
1236 return m, nil
1237 }
1238 }
1239}
1240
Sean McCullough885a16a2025-04-30 02:49:25 +00001241// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001242func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001243 // Reset the start of turn time
1244 a.startOfTurn = time.Now()
1245
Sean McCullough96b60dd2025-04-30 09:49:10 -07001246 // Transition to waiting for user input state
1247 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1248
Sean McCullough885a16a2025-04-30 02:49:25 +00001249 // Process initial user message
1250 initialResp, err := a.processUserMessage(ctx)
1251 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001252 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001253 return err
1254 }
1255
1256 // Handle edge case where both initialResp and err are nil
1257 if initialResp == nil {
1258 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001259 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1260
Sean McCullough9f4b8082025-04-30 17:34:07 +00001261 a.pushToOutbox(ctx, errorMessage(err))
1262 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001263 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001264
Earl Lee2e463fb2025-04-17 11:22:22 -07001265 // We do this as we go, but let's also do it at the end of the turn
1266 defer func() {
1267 if _, err := a.handleGitCommits(ctx); err != nil {
1268 // Just log the error, don't stop execution
1269 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1270 }
1271 }()
1272
Sean McCullougha1e0e492025-05-01 10:51:08 -07001273 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001274 resp := initialResp
1275 for {
1276 // Check if we are over budget
1277 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001278 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001279 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001280 }
1281
1282 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001283 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001284 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001285 break
1286 }
1287
Sean McCullough96b60dd2025-04-30 09:49:10 -07001288 // Transition to tool use requested state
1289 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1290
Sean McCullough885a16a2025-04-30 02:49:25 +00001291 // Handle tool execution
1292 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1293 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001294 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001295 }
1296
Sean McCullougha1e0e492025-05-01 10:51:08 -07001297 if toolResp == nil {
1298 return fmt.Errorf("cannot continue conversation with a nil tool response")
1299 }
1300
Sean McCullough885a16a2025-04-30 02:49:25 +00001301 // Set the response for the next iteration
1302 resp = toolResp
1303 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001304
1305 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001306}
1307
1308// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001309func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001310 // Wait for at least one message from the user
1311 msgs, err := a.GatherMessages(ctx, true)
1312 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001313 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001314 return nil, err
1315 }
1316
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001317 userMessage := llm.Message{
1318 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001319 Content: msgs,
1320 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001321
Sean McCullough96b60dd2025-04-30 09:49:10 -07001322 // Transition to sending to LLM state
1323 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1324
Sean McCullough885a16a2025-04-30 02:49:25 +00001325 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001326 resp, err := a.convo.SendMessage(userMessage)
1327 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001328 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001329 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001330 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001331 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001332
Sean McCullough96b60dd2025-04-30 09:49:10 -07001333 // Transition to processing LLM response state
1334 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1335
Sean McCullough885a16a2025-04-30 02:49:25 +00001336 return resp, nil
1337}
1338
1339// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001340func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1341 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001342 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001343 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001344
Sean McCullough96b60dd2025-04-30 09:49:10 -07001345 // Transition to checking for cancellation state
1346 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1347
Sean McCullough885a16a2025-04-30 02:49:25 +00001348 // Check if the operation was cancelled by the user
1349 select {
1350 case <-ctx.Done():
1351 // Don't actually run any of the tools, but rather build a response
1352 // for each tool_use message letting the LLM know that user canceled it.
1353 var err error
1354 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001355 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001356 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001357 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001358 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001359 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001360 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001361 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001362 // Transition to running tool state
1363 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1364
Sean McCullough885a16a2025-04-30 02:49:25 +00001365 // Add working directory to context for tool execution
1366 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1367
1368 // Execute the tools
1369 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001370 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001371 if ctx.Err() != nil { // e.g. the user canceled the operation
1372 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001373 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001374 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001375 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001376 a.pushToOutbox(ctx, errorMessage(err))
1377 }
1378 }
1379
1380 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001381 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001382 autoqualityMessages := a.processGitChanges(ctx)
1383
1384 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001385 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001386 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001387 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001388 return false, nil
1389 }
1390
1391 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001392 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1393 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001394}
1395
1396// processGitChanges checks for new git commits and runs autoformatters if needed
1397func (a *Agent) processGitChanges(ctx context.Context) []string {
1398 // Check for git commits after tool execution
1399 newCommits, err := a.handleGitCommits(ctx)
1400 if err != nil {
1401 // Just log the error, don't stop execution
1402 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1403 return nil
1404 }
1405
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001406 // Run mechanical checks if there was exactly one new commit.
1407 if len(newCommits) != 1 {
1408 return nil
1409 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001410 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001411 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1412 msg := a.codereview.RunMechanicalChecks(ctx)
1413 if msg != "" {
1414 a.pushToOutbox(ctx, AgentMessage{
1415 Type: AutoMessageType,
1416 Content: msg,
1417 Timestamp: time.Now(),
1418 })
1419 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001420 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001421
1422 return autoqualityMessages
1423}
1424
1425// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001426func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001427 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001428 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001429 msgs, err := a.GatherMessages(ctx, false)
1430 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001431 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001432 return false, nil
1433 }
1434
1435 // Inject any auto-generated messages from quality checks
1436 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001437 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001438 }
1439
1440 // Handle cancellation by appending a message about it
1441 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001442 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001443 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001444 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001445 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1446 } else if err := a.convo.OverBudget(); err != nil {
1447 // Handle budget issues by appending a message about it
1448 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 -07001449 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001450 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1451 }
1452
1453 // Combine tool results with user messages
1454 results = append(results, msgs...)
1455
1456 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001457 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001458 resp, err := a.convo.SendMessage(llm.Message{
1459 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001460 Content: results,
1461 })
1462 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001463 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001464 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1465 return true, nil // Return true to continue the conversation, but with no response
1466 }
1467
Sean McCullough96b60dd2025-04-30 09:49:10 -07001468 // Transition back to processing LLM response
1469 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1470
Sean McCullough885a16a2025-04-30 02:49:25 +00001471 if cancelled {
1472 return false, nil
1473 }
1474
1475 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001476}
1477
1478func (a *Agent) overBudget(ctx context.Context) error {
1479 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001480 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001481 m := budgetMessage(err)
1482 m.Content = m.Content + "\n\nBudget reset."
1483 a.pushToOutbox(ctx, budgetMessage(err))
1484 a.convo.ResetBudget(a.originalBudget)
1485 return err
1486 }
1487 return nil
1488}
1489
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001490func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001491 // Collect all text content
1492 var allText strings.Builder
1493 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001494 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001495 if allText.Len() > 0 {
1496 allText.WriteString("\n\n")
1497 }
1498 allText.WriteString(content.Text)
1499 }
1500 }
1501 return allText.String()
1502}
1503
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001504func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001505 a.mu.Lock()
1506 defer a.mu.Unlock()
1507 return a.convo.CumulativeUsage()
1508}
1509
Earl Lee2e463fb2025-04-17 11:22:22 -07001510// Diff returns a unified diff of changes made since the agent was instantiated.
1511func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001512 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001513 return "", fmt.Errorf("no initial commit reference available")
1514 }
1515
1516 // Find the repository root
1517 ctx := context.Background()
1518
1519 // If a specific commit hash is provided, show just that commit's changes
1520 if commit != nil && *commit != "" {
1521 // Validate that the commit looks like a valid git SHA
1522 if !isValidGitSHA(*commit) {
1523 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1524 }
1525
1526 // Get the diff for just this commit
1527 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1528 cmd.Dir = a.repoRoot
1529 output, err := cmd.CombinedOutput()
1530 if err != nil {
1531 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1532 }
1533 return string(output), nil
1534 }
1535
1536 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001537 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001538 cmd.Dir = a.repoRoot
1539 output, err := cmd.CombinedOutput()
1540 if err != nil {
1541 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1542 }
1543
1544 return string(output), nil
1545}
1546
Philip Zeyliger49edc922025-05-14 09:45:45 -07001547// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1548// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1549func (a *Agent) SketchGitBaseRef() string {
1550 if a.IsInContainer() {
1551 return "sketch-base"
1552 } else {
1553 return "sketch-base-" + a.SessionID()
1554 }
1555}
1556
1557// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1558func (a *Agent) SketchGitBase() string {
1559 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1560 cmd.Dir = a.repoRoot
1561 output, err := cmd.CombinedOutput()
1562 if err != nil {
1563 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1564 return "HEAD"
1565 }
1566 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001567}
1568
Pokey Rule7a113622025-05-12 10:58:45 +01001569// removeGitHooks removes the Git hooks directory from the repository
1570func removeGitHooks(_ context.Context, repoPath string) error {
1571 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1572
1573 // Check if hooks directory exists
1574 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1575 // Directory doesn't exist, nothing to do
1576 return nil
1577 }
1578
1579 // Remove the hooks directory
1580 err := os.RemoveAll(hooksDir)
1581 if err != nil {
1582 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1583 }
1584
1585 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001586 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001587 if err != nil {
1588 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1589 }
1590
1591 return nil
1592}
1593
Earl Lee2e463fb2025-04-17 11:22:22 -07001594// handleGitCommits() highlights new commits to the user. When running
1595// under docker, new HEADs are pushed to a branch according to the title.
1596func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1597 if a.repoRoot == "" {
1598 return nil, nil
1599 }
1600
1601 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1602 if err != nil {
1603 return nil, err
1604 }
1605 if head == a.lastHEAD {
1606 return nil, nil // nothing to do
1607 }
1608 defer func() {
1609 a.lastHEAD = head
1610 }()
1611
1612 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1613 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1614 // to the last 100 commits.
1615 var commits []*GitCommit
1616
1617 // Get commits since the initial commit
1618 // Format: <hash>\0<subject>\0<body>\0
1619 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1620 // Limit to 100 commits to avoid overwhelming the user
Philip Zeyliger49edc922025-05-14 09:45:45 -07001621 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 -07001622 cmd.Dir = a.repoRoot
1623 output, err := cmd.Output()
1624 if err != nil {
1625 return nil, fmt.Errorf("failed to get git log: %w", err)
1626 }
1627
1628 // Parse git log output and filter out already seen commits
1629 parsedCommits := parseGitLog(string(output))
1630
1631 var headCommit *GitCommit
1632
1633 // Filter out commits we've already seen
1634 for _, commit := range parsedCommits {
1635 if commit.Hash == head {
1636 headCommit = &commit
1637 }
1638
1639 // Skip if we've seen this commit before. If our head has changed, always include that.
1640 if a.seenCommits[commit.Hash] && commit.Hash != head {
1641 continue
1642 }
1643
1644 // Mark this commit as seen
1645 a.seenCommits[commit.Hash] = true
1646
1647 // Add to our list of new commits
1648 commits = append(commits, &commit)
1649 }
1650
1651 if a.gitRemoteAddr != "" {
1652 if headCommit == nil {
1653 // I think this can only happen if we have a bug or if there's a race.
1654 headCommit = &GitCommit{}
1655 headCommit.Hash = head
1656 headCommit.Subject = "unknown"
1657 commits = append(commits, headCommit)
1658 }
1659
Philip Zeyliger113e2052025-05-09 21:59:40 +00001660 originalBranch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
1661 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001662
1663 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1664 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1665 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001666
1667 // Try up to 10 times with different branch names if the branch is checked out on the remote
1668 var out []byte
1669 var err error
1670 for retries := range 10 {
1671 if retries > 0 {
1672 // Add a numeric suffix to the branch name
1673 branch = fmt.Sprintf("%s%d", originalBranch, retries)
1674 }
1675
1676 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1677 cmd.Dir = a.workingDir
1678 out, err = cmd.CombinedOutput()
1679
1680 if err == nil {
1681 // Success! Break out of the retry loop
1682 break
1683 }
1684
1685 // Check if this is the "refusing to update checked out branch" error
1686 if !strings.Contains(string(out), "refusing to update checked out branch") {
1687 // This is a different error, so don't retry
1688 break
1689 }
1690
1691 // If we're on the last retry, we'll report the error
1692 if retries == 9 {
1693 break
1694 }
1695 }
1696
1697 if err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001698 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1699 } else {
1700 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001701 // Update the agent's branch name if we ended up using a different one
1702 if branch != originalBranch {
1703 a.branchName = branch
1704 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001705 }
1706 }
1707
1708 // If we found new commits, create a message
1709 if len(commits) > 0 {
1710 msg := AgentMessage{
1711 Type: CommitMessageType,
1712 Timestamp: time.Now(),
1713 Commits: commits,
1714 }
1715 a.pushToOutbox(ctx, msg)
1716 }
1717 return commits, nil
1718}
1719
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001720func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001721 return strings.Map(func(r rune) rune {
1722 // lowercase
1723 if r >= 'A' && r <= 'Z' {
1724 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001725 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001726 // replace spaces with dashes
1727 if r == ' ' {
1728 return '-'
1729 }
1730 // allow alphanumerics and dashes
1731 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1732 return r
1733 }
1734 return -1
1735 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001736}
1737
1738// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1739// and returns an array of GitCommit structs.
1740func parseGitLog(output string) []GitCommit {
1741 var commits []GitCommit
1742
1743 // No output means no commits
1744 if len(output) == 0 {
1745 return commits
1746 }
1747
1748 // Split by NULL byte
1749 parts := strings.Split(output, "\x00")
1750
1751 // Process in triplets (hash, subject, body)
1752 for i := 0; i < len(parts); i++ {
1753 // Skip empty parts
1754 if parts[i] == "" {
1755 continue
1756 }
1757
1758 // This should be a hash
1759 hash := strings.TrimSpace(parts[i])
1760
1761 // Make sure we have at least a subject part available
1762 if i+1 >= len(parts) {
1763 break // No more parts available
1764 }
1765
1766 // Get the subject
1767 subject := strings.TrimSpace(parts[i+1])
1768
1769 // Get the body if available
1770 body := ""
1771 if i+2 < len(parts) {
1772 body = strings.TrimSpace(parts[i+2])
1773 }
1774
1775 // Skip to the next triplet
1776 i += 2
1777
1778 commits = append(commits, GitCommit{
1779 Hash: hash,
1780 Subject: subject,
1781 Body: body,
1782 })
1783 }
1784
1785 return commits
1786}
1787
1788func repoRoot(ctx context.Context, dir string) (string, error) {
1789 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1790 stderr := new(strings.Builder)
1791 cmd.Stderr = stderr
1792 cmd.Dir = dir
1793 out, err := cmd.Output()
1794 if err != nil {
1795 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1796 }
1797 return strings.TrimSpace(string(out)), nil
1798}
1799
1800func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1801 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1802 stderr := new(strings.Builder)
1803 cmd.Stderr = stderr
1804 cmd.Dir = dir
1805 out, err := cmd.Output()
1806 if err != nil {
1807 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1808 }
1809 // TODO: validate that out is valid hex
1810 return strings.TrimSpace(string(out)), nil
1811}
1812
1813// isValidGitSHA validates if a string looks like a valid git SHA hash.
1814// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1815func isValidGitSHA(sha string) bool {
1816 // Git SHA must be a hexadecimal string with at least 4 characters
1817 if len(sha) < 4 || len(sha) > 40 {
1818 return false
1819 }
1820
1821 // Check if the string only contains hexadecimal characters
1822 for _, char := range sha {
1823 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1824 return false
1825 }
1826 }
1827
1828 return true
1829}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001830
1831// getGitOrigin returns the URL of the git remote 'origin' if it exists
1832func getGitOrigin(ctx context.Context, dir string) string {
1833 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1834 cmd.Dir = dir
1835 stderr := new(strings.Builder)
1836 cmd.Stderr = stderr
1837 out, err := cmd.Output()
1838 if err != nil {
1839 return ""
1840 }
1841 return strings.TrimSpace(string(out))
1842}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001843
1844func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1845 cmd := exec.CommandContext(ctx, "git", "stash")
1846 cmd.Dir = workingDir
1847 if out, err := cmd.CombinedOutput(); err != nil {
1848 return fmt.Errorf("git stash: %s: %v", out, err)
1849 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001850 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001851 cmd.Dir = workingDir
1852 if out, err := cmd.CombinedOutput(); err != nil {
1853 return fmt.Errorf("git fetch: %s: %w", out, err)
1854 }
1855 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1856 cmd.Dir = workingDir
1857 if out, err := cmd.CombinedOutput(); err != nil {
1858 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1859 }
1860 a.lastHEAD = revision
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001861 return nil
1862}
1863
1864func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1865 a.mu.Lock()
1866 a.title = ""
1867 a.firstMessageIndex = len(a.history)
1868 a.convo = a.initConvo()
1869 gitReset := func() error {
1870 if a.config.InDocker && rev != "" {
1871 err := a.initGitRevision(ctx, a.workingDir, rev)
1872 if err != nil {
1873 return err
1874 }
1875 } else if !a.config.InDocker && rev != "" {
1876 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1877 }
1878 return nil
1879 }
1880 err := gitReset()
1881 a.mu.Unlock()
1882 if err != nil {
1883 a.pushToOutbox(a.config.Context, errorMessage(err))
1884 }
1885
1886 a.pushToOutbox(a.config.Context, AgentMessage{
1887 Type: AgentMessageType, Content: "Conversation restarted.",
1888 })
1889 if initialPrompt != "" {
1890 a.UserMessage(ctx, initialPrompt)
1891 }
1892 return nil
1893}
1894
1895func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1896 msg := `The user has requested a suggestion for a re-prompt.
1897
1898 Given the current conversation thus far, suggest a re-prompt that would
1899 capture the instructions and feedback so far, as well as any
1900 research or other information that would be helpful in implementing
1901 the task.
1902
1903 Reply with ONLY the reprompt text.
1904 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001905 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001906 // By doing this in a subconversation, the agent doesn't call tools (because
1907 // there aren't any), and there's not a concurrency risk with on-going other
1908 // outstanding conversations.
1909 convo := a.convo.SubConvoWithHistory()
1910 resp, err := convo.SendMessage(userMessage)
1911 if err != nil {
1912 a.pushToOutbox(ctx, errorMessage(err))
1913 return "", err
1914 }
1915 textContent := collectTextContent(resp)
1916 return textContent, nil
1917}
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001918
1919// systemPromptData contains the data used to render the system prompt template
1920type systemPromptData struct {
1921 EditPrompt string
1922 ClientGOOS string
1923 ClientGOARCH string
1924 WorkingDir string
1925 RepoRoot string
1926 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001927 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001928}
1929
1930// renderSystemPrompt renders the system prompt template.
1931func (a *Agent) renderSystemPrompt() string {
1932 // Determine the appropriate edit prompt based on config
1933 var editPrompt string
1934 if a.config.UseAnthropicEdit {
1935 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."
1936 } else {
1937 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1938 }
1939
1940 data := systemPromptData{
1941 EditPrompt: editPrompt,
1942 ClientGOOS: a.config.ClientGOOS,
1943 ClientGOARCH: a.config.ClientGOARCH,
1944 WorkingDir: a.workingDir,
1945 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07001946 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001947 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001948 }
1949
1950 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1951 if err != nil {
1952 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1953 }
1954 buf := new(strings.Builder)
1955 err = tmpl.Execute(buf, data)
1956 if err != nil {
1957 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1958 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001959 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001960 return buf.String()
1961}
Philip Zeyligereab12de2025-05-14 02:35:53 +00001962
1963// StateTransitionIterator provides an iterator over state transitions.
1964type StateTransitionIterator interface {
1965 // Next blocks until a new state transition is available or context is done.
1966 // Returns nil if the context is cancelled.
1967 Next() *StateTransition
1968 // Close removes the listener and cleans up resources.
1969 Close()
1970}
1971
1972// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
1973type StateTransitionIteratorImpl struct {
1974 agent *Agent
1975 ctx context.Context
1976 ch chan StateTransition
1977 unsubscribe func()
1978}
1979
1980// Next blocks until a new state transition is available or the context is cancelled.
1981func (s *StateTransitionIteratorImpl) Next() *StateTransition {
1982 select {
1983 case <-s.ctx.Done():
1984 return nil
1985 case transition, ok := <-s.ch:
1986 if !ok {
1987 return nil
1988 }
1989 transitionCopy := transition
1990 return &transitionCopy
1991 }
1992}
1993
1994// Close removes the listener and cleans up resources.
1995func (s *StateTransitionIteratorImpl) Close() {
1996 if s.unsubscribe != nil {
1997 s.unsubscribe()
1998 s.unsubscribe = nil
1999 }
2000}
2001
2002// NewStateTransitionIterator returns an iterator that receives state transitions.
2003func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2004 a.mu.Lock()
2005 defer a.mu.Unlock()
2006
2007 // Create channel to receive state transitions
2008 ch := make(chan StateTransition, 10)
2009
2010 // Add a listener to the state machine
2011 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2012
2013 return &StateTransitionIteratorImpl{
2014 agent: a,
2015 ctx: ctx,
2016 ch: ch,
2017 unsubscribe: unsubscribe,
2018 }
2019}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002020
2021// setupGitHooks creates or updates git hooks in the specified working directory.
2022func setupGitHooks(workingDir string) error {
2023 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2024
2025 _, err := os.Stat(hooksDir)
2026 if os.IsNotExist(err) {
2027 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2028 }
2029 if err != nil {
2030 return fmt.Errorf("error checking git hooks directory: %w", err)
2031 }
2032
2033 // Define the post-commit hook content
2034 postCommitHook := `#!/bin/bash
2035echo "<post_commit_hook>"
2036echo "Please review this commit message and fix it if it is incorrect."
2037echo "This hook only echos the commit message; it does not modify it."
2038echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2039echo "<last_commit_message>"
2040git log -1 --pretty=%B
2041echo "</last_commit_message>"
2042echo "</post_commit_hook>"
2043`
2044
2045 // Define the prepare-commit-msg hook content
2046 prepareCommitMsgHook := `#!/bin/bash
2047# Add Co-Authored-By and Change-ID trailers to commit messages
2048# Check if these trailers already exist before adding them
2049
2050commit_file="$1"
2051COMMIT_SOURCE="$2"
2052
2053# Skip for merges, squashes, or when using a commit template
2054if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2055 [ "$COMMIT_SOURCE" = "squash" ]; then
2056 exit 0
2057fi
2058
2059commit_msg=$(cat "$commit_file")
2060
2061needs_co_author=true
2062needs_change_id=true
2063
2064# Check if commit message already has Co-Authored-By trailer
2065if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2066 needs_co_author=false
2067fi
2068
2069# Check if commit message already has Change-ID trailer
2070if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2071 needs_change_id=false
2072fi
2073
2074# Only modify if at least one trailer needs to be added
2075if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
2076 # Ensure there's a blank line before trailers
2077 if [ -s "$commit_file" ] && [ "$(tail -1 "$commit_file" | tr -d '\n')" != "" ]; then
2078 echo "" >> "$commit_file"
2079 fi
2080
2081 # Add trailers if needed
2082 if [ "$needs_co_author" = true ]; then
2083 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2084 fi
2085
2086 if [ "$needs_change_id" = true ]; then
2087 change_id=$(openssl rand -hex 8)
2088 echo "Change-ID: s${change_id}k" >> "$commit_file"
2089 fi
2090fi
2091`
2092
2093 // Update or create the post-commit hook
2094 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2095 if err != nil {
2096 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2097 }
2098
2099 // Update or create the prepare-commit-msg hook
2100 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2101 if err != nil {
2102 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2103 }
2104
2105 return nil
2106}
2107
2108// updateOrCreateHook creates a new hook file or updates an existing one
2109// by appending the new content if it doesn't already contain it.
2110func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2111 // Check if the hook already exists
2112 buf, err := os.ReadFile(hookPath)
2113 if os.IsNotExist(err) {
2114 // Hook doesn't exist, create it
2115 err = os.WriteFile(hookPath, []byte(content), 0o755)
2116 if err != nil {
2117 return fmt.Errorf("failed to create hook: %w", err)
2118 }
2119 return nil
2120 }
2121 if err != nil {
2122 return fmt.Errorf("error reading existing hook: %w", err)
2123 }
2124
2125 // Hook exists, check if our content is already in it by looking for a distinctive line
2126 code := string(buf)
2127 if strings.Contains(code, distinctiveLine) {
2128 // Already contains our content, nothing to do
2129 return nil
2130 }
2131
2132 // Append our content to the existing hook
2133 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2134 if err != nil {
2135 return fmt.Errorf("failed to open hook for appending: %w", err)
2136 }
2137 defer f.Close()
2138
2139 // Ensure there's a newline at the end of the existing content if needed
2140 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2141 _, err = f.WriteString("\n")
2142 if err != nil {
2143 return fmt.Errorf("failed to add newline to hook: %w", err)
2144 }
2145 }
2146
2147 // Add a separator before our content
2148 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2149 if err != nil {
2150 return fmt.Errorf("failed to append to hook: %w", err)
2151 }
2152
2153 return nil
2154}