blob: 304b8cd138cdf984918fabc21e5ecc10034cb2d7 [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
92 // Title returns the current title of the conversation.
93 Title() string
94
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000095 // BranchName returns the git branch name for the conversation.
96 BranchName() string
97
Earl Lee2e463fb2025-04-17 11:22:22 -070098 // OS returns the operating system of the client.
99 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000100
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000101 // SessionID returns the unique session identifier.
102 SessionID() string
103
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000104 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
105 OutstandingLLMCallCount() int
106
107 // OutstandingToolCalls returns the names of outstanding tool calls.
108 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000109 OutsideOS() string
110 OutsideHostname() string
111 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000112 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000113 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
114 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700115
116 // RestartConversation resets the conversation history
117 RestartConversation(ctx context.Context, rev string, initialPrompt string) error
118 // SuggestReprompt suggests a re-prompt based on the current conversation.
119 SuggestReprompt(ctx context.Context) (string, error)
120 // IsInContainer returns true if the agent is running in a container
121 IsInContainer() bool
122 // FirstMessageIndex returns the index of the first message in the current conversation
123 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700124
125 CurrentStateName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700126}
127
128type CodingAgentMessageType string
129
130const (
131 UserMessageType CodingAgentMessageType = "user"
132 AgentMessageType CodingAgentMessageType = "agent"
133 ErrorMessageType CodingAgentMessageType = "error"
134 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
135 ToolUseMessageType CodingAgentMessageType = "tool"
136 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
137 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
138
139 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
140)
141
142type AgentMessage struct {
143 Type CodingAgentMessageType `json:"type"`
144 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
145 EndOfTurn bool `json:"end_of_turn"`
146
147 Content string `json:"content"`
148 ToolName string `json:"tool_name,omitempty"`
149 ToolInput string `json:"input,omitempty"`
150 ToolResult string `json:"tool_result,omitempty"`
151 ToolError bool `json:"tool_error,omitempty"`
152 ToolCallId string `json:"tool_call_id,omitempty"`
153
154 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
155 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
156
Sean McCulloughd9f13372025-04-21 15:08:49 -0700157 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
158 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
159
Earl Lee2e463fb2025-04-17 11:22:22 -0700160 // Commits is a list of git commits for a commit message
161 Commits []*GitCommit `json:"commits,omitempty"`
162
163 Timestamp time.Time `json:"timestamp"`
164 ConversationID string `json:"conversation_id"`
165 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700166 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700167
168 // Message timing information
169 StartTime *time.Time `json:"start_time,omitempty"`
170 EndTime *time.Time `json:"end_time,omitempty"`
171 Elapsed *time.Duration `json:"elapsed,omitempty"`
172
173 // Turn duration - the time taken for a complete agent turn
174 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
175
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000176 // HideOutput indicates that this message should not be rendered in the UI.
177 // This is useful for subconversations that generate output that shouldn't be shown to the user.
178 HideOutput bool `json:"hide_output,omitempty"`
179
Earl Lee2e463fb2025-04-17 11:22:22 -0700180 Idx int `json:"idx"`
181}
182
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000183// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700184func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700185 if convo == nil {
186 m.ConversationID = ""
187 m.ParentConversationID = nil
188 return
189 }
190 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000191 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700192 if convo.Parent != nil {
193 m.ParentConversationID = &convo.Parent.ID
194 }
195}
196
Earl Lee2e463fb2025-04-17 11:22:22 -0700197// GitCommit represents a single git commit for a commit message
198type GitCommit struct {
199 Hash string `json:"hash"` // Full commit hash
200 Subject string `json:"subject"` // Commit subject line
201 Body string `json:"body"` // Full commit message body
202 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
203}
204
205// ToolCall represents a single tool call within an agent message
206type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700207 Name string `json:"name"`
208 Input string `json:"input"`
209 ToolCallId string `json:"tool_call_id"`
210 ResultMessage *AgentMessage `json:"result_message,omitempty"`
211 Args string `json:"args,omitempty"`
212 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700213}
214
215func (a *AgentMessage) Attr() slog.Attr {
216 var attrs []any = []any{
217 slog.String("type", string(a.Type)),
218 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700219 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700220 if a.EndOfTurn {
221 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
222 }
223 if a.Content != "" {
224 attrs = append(attrs, slog.String("content", a.Content))
225 }
226 if a.ToolName != "" {
227 attrs = append(attrs, slog.String("tool_name", a.ToolName))
228 }
229 if a.ToolInput != "" {
230 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
231 }
232 if a.Elapsed != nil {
233 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
234 }
235 if a.TurnDuration != nil {
236 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
237 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700238 if len(a.ToolResult) > 0 {
239 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700240 }
241 if a.ToolError {
242 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
243 }
244 if len(a.ToolCalls) > 0 {
245 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
246 for i, tc := range a.ToolCalls {
247 toolCallAttrs = append(toolCallAttrs, slog.Group(
248 fmt.Sprintf("tool_call_%d", i),
249 slog.String("name", tc.Name),
250 slog.String("input", tc.Input),
251 ))
252 }
253 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
254 }
255 if a.ConversationID != "" {
256 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
257 }
258 if a.ParentConversationID != nil {
259 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
260 }
261 if a.Usage != nil && !a.Usage.IsZero() {
262 attrs = append(attrs, a.Usage.Attr())
263 }
264 // TODO: timestamp, convo ids, idx?
265 return slog.Group("agent_message", attrs...)
266}
267
268func errorMessage(err error) AgentMessage {
269 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
270 if os.Getenv(("DEBUG")) == "1" {
271 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
272 }
273
274 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
275}
276
277func budgetMessage(err error) AgentMessage {
278 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
279}
280
281// ConvoInterface defines the interface for conversation interactions
282type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700283 CumulativeUsage() conversation.CumulativeUsage
284 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700285 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700286 SendMessage(message llm.Message) (*llm.Response, error)
287 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700288 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000289 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700290 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700291 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700292 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700293}
294
295type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700296 convo ConvoInterface
297 config AgentConfig // config for this agent
298 workingDir string
299 repoRoot string // workingDir may be a subdir of repoRoot
300 url string
301 firstMessageIndex int // index of the first message in the current conversation
302 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700303 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000304 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700305 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000306 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700307 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700308 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700309 title string
310 branchName string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000311 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700312 // State machine to track agent state
313 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000314 // Outside information
315 outsideHostname string
316 outsideOS string
317 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000318 // URL of the git remote 'origin' if it exists
319 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700320
321 // Time when the current turn started (reset at the beginning of InnerLoop)
322 startOfTurn time.Time
323
324 // Inbox - for messages from the user to the agent.
325 // sent on by UserMessage
326 // . e.g. when user types into the chat textarea
327 // read from by GatherMessages
328 inbox chan string
329
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000330 // protects cancelTurn
331 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700332 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000333 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700334
335 // protects following
336 mu sync.Mutex
337
338 // Stores all messages for this agent
339 history []AgentMessage
340
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700341 // Iterators add themselves here when they're ready to be notified of new messages.
342 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700343
344 // Track git commits we've already seen (by hash)
345 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000346
347 // Track outstanding LLM call IDs
348 outstandingLLMCalls map[string]struct{}
349
350 // Track outstanding tool calls by ID with their names
351 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700352}
353
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700354// NewIterator implements CodingAgent.
355func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
356 a.mu.Lock()
357 defer a.mu.Unlock()
358
359 return &MessageIteratorImpl{
360 agent: a,
361 ctx: ctx,
362 nextMessageIdx: nextMessageIdx,
363 ch: make(chan *AgentMessage, 100),
364 }
365}
366
367type MessageIteratorImpl struct {
368 agent *Agent
369 ctx context.Context
370 nextMessageIdx int
371 ch chan *AgentMessage
372 subscribed bool
373}
374
375func (m *MessageIteratorImpl) Close() {
376 m.agent.mu.Lock()
377 defer m.agent.mu.Unlock()
378 // Delete ourselves from the subscribers list
379 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
380 return x == m.ch
381 })
382 close(m.ch)
383}
384
385func (m *MessageIteratorImpl) Next() *AgentMessage {
386 // We avoid subscription at creation to let ourselves catch up to "current state"
387 // before subscribing.
388 if !m.subscribed {
389 m.agent.mu.Lock()
390 if m.nextMessageIdx < len(m.agent.history) {
391 msg := &m.agent.history[m.nextMessageIdx]
392 m.nextMessageIdx++
393 m.agent.mu.Unlock()
394 return msg
395 }
396 // The next message doesn't exist yet, so let's subscribe
397 m.agent.subscribers = append(m.agent.subscribers, m.ch)
398 m.subscribed = true
399 m.agent.mu.Unlock()
400 }
401
402 for {
403 select {
404 case <-m.ctx.Done():
405 m.agent.mu.Lock()
406 // Delete ourselves from the subscribers list
407 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
408 return x == m.ch
409 })
410 m.subscribed = false
411 m.agent.mu.Unlock()
412 return nil
413 case msg, ok := <-m.ch:
414 if !ok {
415 // Close may have been called
416 return nil
417 }
418 if msg.Idx == m.nextMessageIdx {
419 m.nextMessageIdx++
420 return msg
421 }
422 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
423 panic("out of order message")
424 }
425 }
426}
427
Sean McCulloughd9d45812025-04-30 16:53:41 -0700428// Assert that Agent satisfies the CodingAgent interface.
429var _ CodingAgent = &Agent{}
430
431// StateName implements CodingAgent.
432func (a *Agent) CurrentStateName() string {
433 if a.stateMachine == nil {
434 return ""
435 }
436 return a.stateMachine.currentState.String()
437}
438
Earl Lee2e463fb2025-04-17 11:22:22 -0700439func (a *Agent) URL() string { return a.url }
440
441// Title returns the current title of the conversation.
442// If no title has been set, returns an empty string.
443func (a *Agent) Title() string {
444 a.mu.Lock()
445 defer a.mu.Unlock()
446 return a.title
447}
448
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000449// BranchName returns the git branch name for the conversation.
450func (a *Agent) BranchName() string {
451 a.mu.Lock()
452 defer a.mu.Unlock()
453 return a.branchName
454}
455
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000456// OutstandingLLMCallCount returns the number of outstanding LLM calls.
457func (a *Agent) OutstandingLLMCallCount() int {
458 a.mu.Lock()
459 defer a.mu.Unlock()
460 return len(a.outstandingLLMCalls)
461}
462
463// OutstandingToolCalls returns the names of outstanding tool calls.
464func (a *Agent) OutstandingToolCalls() []string {
465 a.mu.Lock()
466 defer a.mu.Unlock()
467
468 tools := make([]string, 0, len(a.outstandingToolCalls))
469 for _, toolName := range a.outstandingToolCalls {
470 tools = append(tools, toolName)
471 }
472 return tools
473}
474
Earl Lee2e463fb2025-04-17 11:22:22 -0700475// OS returns the operating system of the client.
476func (a *Agent) OS() string {
477 return a.config.ClientGOOS
478}
479
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000480func (a *Agent) SessionID() string {
481 return a.config.SessionID
482}
483
Philip Zeyliger18532b22025-04-23 21:11:46 +0000484// OutsideOS returns the operating system of the outside system.
485func (a *Agent) OutsideOS() string {
486 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000487}
488
Philip Zeyliger18532b22025-04-23 21:11:46 +0000489// OutsideHostname returns the hostname of the outside system.
490func (a *Agent) OutsideHostname() string {
491 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000492}
493
Philip Zeyliger18532b22025-04-23 21:11:46 +0000494// OutsideWorkingDir returns the working directory on the outside system.
495func (a *Agent) OutsideWorkingDir() string {
496 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000497}
498
499// GitOrigin returns the URL of the git remote 'origin' if it exists.
500func (a *Agent) GitOrigin() string {
501 return a.gitOrigin
502}
503
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000504func (a *Agent) OpenBrowser(url string) {
505 if !a.IsInContainer() {
506 browser.Open(url)
507 return
508 }
509 // We're in Docker, need to send a request to the Git server
510 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700511 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000512 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700513 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000514 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700515 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000516 return
517 }
518 defer resp.Body.Close()
519 if resp.StatusCode == http.StatusOK {
520 return
521 }
522 body, _ := io.ReadAll(resp.Body)
523 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
524}
525
Sean McCullough96b60dd2025-04-30 09:49:10 -0700526// CurrentState returns the current state of the agent's state machine.
527func (a *Agent) CurrentState() State {
528 return a.stateMachine.CurrentState()
529}
530
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700531func (a *Agent) IsInContainer() bool {
532 return a.config.InDocker
533}
534
535func (a *Agent) FirstMessageIndex() int {
536 a.mu.Lock()
537 defer a.mu.Unlock()
538 return a.firstMessageIndex
539}
540
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000541// SetTitle sets the title of the conversation.
542func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700543 a.mu.Lock()
544 defer a.mu.Unlock()
545 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000546}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700547
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000548// SetBranch sets the branch name of the conversation.
549func (a *Agent) SetBranch(branchName string) {
550 a.mu.Lock()
551 defer a.mu.Unlock()
552 a.branchName = branchName
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000553 convo, ok := a.convo.(*conversation.Convo)
554 if ok {
555 convo.ExtraData["branch"] = branchName
556 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700557}
558
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000559// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700560func (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 +0000561 // Track the tool call
562 a.mu.Lock()
563 a.outstandingToolCalls[id] = toolName
564 a.mu.Unlock()
565}
566
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700567// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
568// If there's only one element in the array and it's a text type, it returns that text directly.
569// It also processes nested ToolResult arrays recursively.
570func contentToString(contents []llm.Content) string {
571 if len(contents) == 0 {
572 return ""
573 }
574
575 // If there's only one element and it's a text type, return it directly
576 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
577 return contents[0].Text
578 }
579
580 // Otherwise, concatenate all text content
581 var result strings.Builder
582 for _, content := range contents {
583 if content.Type == llm.ContentTypeText {
584 result.WriteString(content.Text)
585 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
586 // Recursively process nested tool results
587 result.WriteString(contentToString(content.ToolResult))
588 }
589 }
590
591 return result.String()
592}
593
Earl Lee2e463fb2025-04-17 11:22:22 -0700594// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700595func (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 +0000596 // Remove the tool call from outstanding calls
597 a.mu.Lock()
598 delete(a.outstandingToolCalls, toolID)
599 a.mu.Unlock()
600
Earl Lee2e463fb2025-04-17 11:22:22 -0700601 m := AgentMessage{
602 Type: ToolUseMessageType,
603 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700604 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700605 ToolError: content.ToolError,
606 ToolName: toolName,
607 ToolInput: string(toolInput),
608 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700609 StartTime: content.ToolUseStartTime,
610 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700611 }
612
613 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700614 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
615 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700616 m.Elapsed = &elapsed
617 }
618
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700619 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700620 a.pushToOutbox(ctx, m)
621}
622
623// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700624func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000625 a.mu.Lock()
626 defer a.mu.Unlock()
627 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700628 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
629}
630
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700631// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700632// that need to be displayed (as well as tool calls that we send along when
633// they're done). (It would be reasonable to also mention tool calls when they're
634// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700635func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000636 // Remove the LLM call from outstanding calls
637 a.mu.Lock()
638 delete(a.outstandingLLMCalls, id)
639 a.mu.Unlock()
640
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700641 if resp == nil {
642 // LLM API call failed
643 m := AgentMessage{
644 Type: ErrorMessageType,
645 Content: "API call failed, type 'continue' to try again",
646 }
647 m.SetConvo(convo)
648 a.pushToOutbox(ctx, m)
649 return
650 }
651
Earl Lee2e463fb2025-04-17 11:22:22 -0700652 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700653 if convo.Parent == nil { // subconvos never end the turn
654 switch resp.StopReason {
655 case llm.StopReasonToolUse:
656 // Check whether any of the tool calls are for tools that should end the turn
657 ToolSearch:
658 for _, part := range resp.Content {
659 if part.Type != llm.ContentTypeToolUse {
660 continue
661 }
Sean McCullough021557a2025-05-05 23:20:53 +0000662 // Find the tool by name
663 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700664 if tool.Name == part.ToolName {
665 endOfTurn = tool.EndsTurn
666 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000667 }
668 }
Sean McCullough021557a2025-05-05 23:20:53 +0000669 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700670 default:
671 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000672 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700673 }
674 m := AgentMessage{
675 Type: AgentMessageType,
676 Content: collectTextContent(resp),
677 EndOfTurn: endOfTurn,
678 Usage: &resp.Usage,
679 StartTime: resp.StartTime,
680 EndTime: resp.EndTime,
681 }
682
683 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700684 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700685 var toolCalls []ToolCall
686 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700687 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700688 toolCalls = append(toolCalls, ToolCall{
689 Name: part.ToolName,
690 Input: string(part.ToolInput),
691 ToolCallId: part.ID,
692 })
693 }
694 }
695 m.ToolCalls = toolCalls
696 }
697
698 // Calculate the elapsed time if both start and end times are set
699 if resp.StartTime != nil && resp.EndTime != nil {
700 elapsed := resp.EndTime.Sub(*resp.StartTime)
701 m.Elapsed = &elapsed
702 }
703
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700704 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700705 a.pushToOutbox(ctx, m)
706}
707
708// WorkingDir implements CodingAgent.
709func (a *Agent) WorkingDir() string {
710 return a.workingDir
711}
712
713// MessageCount implements CodingAgent.
714func (a *Agent) MessageCount() int {
715 a.mu.Lock()
716 defer a.mu.Unlock()
717 return len(a.history)
718}
719
720// Messages implements CodingAgent.
721func (a *Agent) Messages(start int, end int) []AgentMessage {
722 a.mu.Lock()
723 defer a.mu.Unlock()
724 return slices.Clone(a.history[start:end])
725}
726
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700727func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700728 return a.originalBudget
729}
730
731// AgentConfig contains configuration for creating a new Agent.
732type AgentConfig struct {
733 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700734 Service llm.Service
735 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700736 GitUsername string
737 GitEmail string
738 SessionID string
739 ClientGOOS string
740 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700741 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700742 UseAnthropicEdit bool
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000743 OneShot bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000744 // Outside information
745 OutsideHostname string
746 OutsideOS string
747 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700748}
749
750// NewAgent creates a new Agent.
751// It is not usable until Init() is called.
752func NewAgent(config AgentConfig) *Agent {
753 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000754 config: config,
755 ready: make(chan struct{}),
756 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700757 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000758 startedAt: time.Now(),
759 originalBudget: config.Budget,
760 seenCommits: make(map[string]bool),
761 outsideHostname: config.OutsideHostname,
762 outsideOS: config.OutsideOS,
763 outsideWorkingDir: config.OutsideWorkingDir,
764 outstandingLLMCalls: make(map[string]struct{}),
765 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700766 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700767 }
768 return agent
769}
770
771type AgentInit struct {
772 WorkingDir string
773 NoGit bool // only for testing
774
775 InDocker bool
776 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000777 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700778 GitRemoteAddr string
779 HostAddr string
780}
781
782func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700783 if a.convo != nil {
784 return fmt.Errorf("Agent.Init: already initialized")
785 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700786 ctx := a.config.Context
787 if ini.InDocker {
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +0000788 if err := setupGitHooks(ini.WorkingDir); err != nil {
789 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
790 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700791 cmd := exec.CommandContext(ctx, "git", "stash")
792 cmd.Dir = ini.WorkingDir
793 if out, err := cmd.CombinedOutput(); err != nil {
794 return fmt.Errorf("git stash: %s: %v", out, err)
795 }
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700796 // sketch-host is a git repo hosted by "outtie sketch". When it notices a 'git fetch',
797 // it runs "git fetch" underneath the covers to get its latest commits. By configuring
798 // an additional remote.sketch-host.fetch, we make "origin/main" on innie sketch look like
799 // origin/main on outtie sketch, which should make it easier to rebase.
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700800 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
801 cmd.Dir = ini.WorkingDir
802 if out, err := cmd.CombinedOutput(); err != nil {
803 return fmt.Errorf("git remote add: %s: %v", out, err)
804 }
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700805 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.sketch-host.fetch",
806 "+refs/heads/feature/*:refs/remotes/origin/feature/*")
807 cmd.Dir = ini.WorkingDir
808 if out, err := cmd.CombinedOutput(); err != nil {
809 return fmt.Errorf("git config --add: %s: %v", out, err)
810 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000811 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700812 cmd.Dir = ini.WorkingDir
813 if out, err := cmd.CombinedOutput(); err != nil {
814 return fmt.Errorf("git fetch: %s: %w", out, err)
815 }
816 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
817 cmd.Dir = ini.WorkingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100818 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
819 // Remove git hooks if they exist and retry
820 // Only try removing hooks if we haven't already removed them during fetch
821 hookPath := filepath.Join(ini.WorkingDir, ".git", "hooks")
822 if _, statErr := os.Stat(hookPath); statErr == nil {
823 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
824 slog.String("error", err.Error()),
825 slog.String("output", string(checkoutOut)))
826 if removeErr := removeGitHooks(ctx, ini.WorkingDir); removeErr != nil {
827 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
828 }
829
830 // Retry the checkout operation
831 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
832 cmd.Dir = ini.WorkingDir
833 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
834 return fmt.Errorf("git checkout %s failed even after removing hooks: %s: %w", ini.Commit, retryOut, retryErr)
835 }
836 } else {
837 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, checkoutOut, err)
838 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700839 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700840 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000841 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700842 if ini.HostAddr != "" {
843 a.url = "http://" + ini.HostAddr
844 }
845 }
846 a.workingDir = ini.WorkingDir
847
848 if !ini.NoGit {
849 repoRoot, err := repoRoot(ctx, a.workingDir)
850 if err != nil {
851 return fmt.Errorf("repoRoot: %w", err)
852 }
853 a.repoRoot = repoRoot
854
Earl Lee2e463fb2025-04-17 11:22:22 -0700855 if err != nil {
856 return fmt.Errorf("resolveRef: %w", err)
857 }
Philip Zeyliger49edc922025-05-14 09:45:45 -0700858
859 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
860 cmd.Dir = repoRoot
861 if out, err := cmd.CombinedOutput(); err != nil {
862 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
863 }
864 a.lastHEAD = ini.Commit
Earl Lee2e463fb2025-04-17 11:22:22 -0700865
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +0000866 slog.Info("running codebase analysis")
867 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
868 if err != nil {
869 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000870 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +0000871 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000872
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000873 llmCodeReview := codereview.NoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700874 if experiment.Enabled("llm_review") {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000875 llmCodeReview = codereview.DoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700876 }
Philip Zeyliger49edc922025-05-14 09:45:45 -0700877 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef(), llmCodeReview)
Earl Lee2e463fb2025-04-17 11:22:22 -0700878 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000879 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700880 }
881 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000882
883 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700884 }
Philip Zeyliger49edc922025-05-14 09:45:45 -0700885 a.lastHEAD = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -0700886 a.convo = a.initConvo()
887 close(a.ready)
888 return nil
889}
890
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700891//go:embed agent_system_prompt.txt
892var agentSystemPrompt string
893
Earl Lee2e463fb2025-04-17 11:22:22 -0700894// initConvo initializes the conversation.
895// It must not be called until all agent fields are initialized,
896// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700897func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700898 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700899 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700900 convo.PromptCaching = true
901 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000902 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000903 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -0700904
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000905 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
906 bashPermissionCheck := func(command string) error {
907 // Check if branch name is set
908 a.mu.Lock()
909 branchSet := a.branchName != ""
910 a.mu.Unlock()
911
912 // If branch is set, all commands are allowed
913 if branchSet {
914 return nil
915 }
916
917 // If branch is not set, check if this is a git commit command
918 willCommit, err := bashkit.WillRunGitCommit(command)
919 if err != nil {
920 // If there's an error checking, we should allow the command to proceed
921 return nil
922 }
923
924 // If it's a git commit and branch is not set, return an error
925 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000926 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000927 }
928
929 return nil
930 }
931
932 // Create a custom bash tool with the permission check
933 bashTool := claudetool.NewBashTool(bashPermissionCheck)
934
Earl Lee2e463fb2025-04-17 11:22:22 -0700935 // Register all tools with the conversation
936 // When adding, removing, or modifying tools here, double-check that the termui tool display
937 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000938
939 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -0700940 _, supportsScreenshots := a.config.Service.(*ant.Service)
941 var bTools []*llm.Tool
942 var browserCleanup func()
943
944 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
945 // Add cleanup function to context cancel
946 go func() {
947 <-a.config.Context.Done()
948 browserCleanup()
949 }()
950 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000951
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700952 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000953 bashTool, claudetool.Keyword,
Josh Bleecher Snyder93202652025-05-08 02:05:57 +0000954 claudetool.Think, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -0700955 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000956 }
957
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000958 // One-shot mode is non-interactive, multiple choice requires human response
959 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -0700960 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -0700961 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000962
963 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -0700964 if a.config.UseAnthropicEdit {
965 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
966 } else {
967 convo.Tools = append(convo.Tools, claudetool.Patch)
968 }
969 convo.Listener = a
970 return convo
971}
972
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -0700973var multipleChoiceTool = &llm.Tool{
974 Name: "multiplechoice",
975 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.",
976 EndsTurn: true,
977 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -0700978 "type": "object",
979 "description": "The question and a list of answers you would expect the user to choose from.",
980 "properties": {
981 "question": {
982 "type": "string",
983 "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?'"
984 },
985 "responseOptions": {
986 "type": "array",
987 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
988 "items": {
989 "type": "object",
990 "properties": {
991 "caption": {
992 "type": "string",
993 "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'"
994 },
995 "responseText": {
996 "type": "string",
997 "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'"
998 }
999 },
1000 "required": ["caption", "responseText"]
1001 }
1002 }
1003 },
1004 "required": ["question", "responseOptions"]
1005}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001006 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1007 // The Run logic for "multiplechoice" tool is a no-op on the server.
1008 // The UI will present a list of options for the user to select from,
1009 // and that's it as far as "executing" the tool_use goes.
1010 // When the user *does* select one of the presented options, that
1011 // responseText gets sent as a chat message on behalf of the user.
1012 return llm.TextContent("end your turn and wait for the user to respond"), nil
1013 },
Sean McCullough485afc62025-04-28 14:28:39 -07001014}
1015
1016type MultipleChoiceOption struct {
1017 Caption string `json:"caption"`
1018 ResponseText string `json:"responseText"`
1019}
1020
1021type MultipleChoiceParams struct {
1022 Question string `json:"question"`
1023 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1024}
1025
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001026// branchExists reports whether branchName exists, either locally or in well-known remotes.
1027func branchExists(dir, branchName string) bool {
1028 refs := []string{
1029 "refs/heads/",
1030 "refs/remotes/origin/",
1031 "refs/remotes/sketch-host/",
1032 }
1033 for _, ref := range refs {
1034 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1035 cmd.Dir = dir
1036 if cmd.Run() == nil { // exit code 0 means branch exists
1037 return true
1038 }
1039 }
1040 return false
1041}
1042
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001043func (a *Agent) titleTool() *llm.Tool {
1044 description := `Sets the conversation title.`
1045 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001046 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001047 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001048 InputSchema: json.RawMessage(`{
1049 "type": "object",
1050 "properties": {
1051 "title": {
1052 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001053 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001054 }
1055 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001056 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001057}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001058 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001059 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001060 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001061 }
1062 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001063 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001064 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001065
1066 // We don't allow changing the title once set to be consistent with the previous behavior
1067 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001068 t := a.Title()
1069 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001070 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001071 }
1072
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001073 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001074 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001075 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001076
1077 a.SetTitle(params.Title)
1078 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001079 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001080 },
1081 }
1082 return titleTool
1083}
1084
1085func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001086 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 +00001087 preCommit := &llm.Tool{
1088 Name: "precommit",
1089 Description: description,
1090 InputSchema: json.RawMessage(`{
1091 "type": "object",
1092 "properties": {
1093 "branch_name": {
1094 "type": "string",
1095 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1096 }
1097 },
1098 "required": ["branch_name"]
1099}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001100 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001101 var params struct {
1102 BranchName string `json:"branch_name"`
1103 }
1104 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001105 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001106 }
1107
1108 b := a.BranchName()
1109 if b != "" {
Josh Bleecher Snyder44d1f1a2025-05-12 19:18:32 -07001110 return nil, fmt.Errorf("branch already set to %s; do not create a new branch", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001111 }
1112
1113 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001114 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001115 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001116 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001117 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001118 }
1119 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001120 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001121 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001122 }
1123
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001124 a.SetBranch(branchName)
Josh Bleecher Snyderf7bebdd2025-05-14 15:22:24 -07001125 response := fmt.Sprintf("switched to branch sketch/%q - DO NOT change branches unless explicitly requested", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001126
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001127 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1128 if err != nil {
1129 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1130 }
1131 if len(styleHint) > 0 {
1132 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001133 }
1134
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001135 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001136 },
1137 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001138 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001139}
1140
1141func (a *Agent) Ready() <-chan struct{} {
1142 return a.ready
1143}
1144
1145func (a *Agent) UserMessage(ctx context.Context, msg string) {
1146 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1147 a.inbox <- msg
1148}
1149
Earl Lee2e463fb2025-04-17 11:22:22 -07001150func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1151 return a.convo.CancelToolUse(toolUseID, cause)
1152}
1153
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001154func (a *Agent) CancelTurn(cause error) {
1155 a.cancelTurnMu.Lock()
1156 defer a.cancelTurnMu.Unlock()
1157 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001158 // Force state transition to cancelled state
1159 ctx := a.config.Context
1160 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001161 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001162 }
1163}
1164
1165func (a *Agent) Loop(ctxOuter context.Context) {
1166 for {
1167 select {
1168 case <-ctxOuter.Done():
1169 return
1170 default:
1171 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001172 a.cancelTurnMu.Lock()
1173 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001174 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001175 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001176 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001177 a.cancelTurn = cancel
1178 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001179 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1180 if err != nil {
1181 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1182 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001183 cancel(nil)
1184 }
1185 }
1186}
1187
1188func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1189 if m.Timestamp.IsZero() {
1190 m.Timestamp = time.Now()
1191 }
1192
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001193 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1194 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1195 m.Content = m.ToolResult
1196 }
1197
Earl Lee2e463fb2025-04-17 11:22:22 -07001198 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1199 if m.EndOfTurn && m.Type == AgentMessageType {
1200 turnDuration := time.Since(a.startOfTurn)
1201 m.TurnDuration = &turnDuration
1202 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1203 }
1204
Earl Lee2e463fb2025-04-17 11:22:22 -07001205 a.mu.Lock()
1206 defer a.mu.Unlock()
1207 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001208 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001209 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001210
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001211 // Notify all subscribers
1212 for _, ch := range a.subscribers {
1213 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001214 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001215}
1216
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001217func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1218 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001219 if block {
1220 select {
1221 case <-ctx.Done():
1222 return m, ctx.Err()
1223 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001224 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001225 }
1226 }
1227 for {
1228 select {
1229 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001230 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001231 default:
1232 return m, nil
1233 }
1234 }
1235}
1236
Sean McCullough885a16a2025-04-30 02:49:25 +00001237// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001238func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001239 // Reset the start of turn time
1240 a.startOfTurn = time.Now()
1241
Sean McCullough96b60dd2025-04-30 09:49:10 -07001242 // Transition to waiting for user input state
1243 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1244
Sean McCullough885a16a2025-04-30 02:49:25 +00001245 // Process initial user message
1246 initialResp, err := a.processUserMessage(ctx)
1247 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001248 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001249 return err
1250 }
1251
1252 // Handle edge case where both initialResp and err are nil
1253 if initialResp == nil {
1254 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001255 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1256
Sean McCullough9f4b8082025-04-30 17:34:07 +00001257 a.pushToOutbox(ctx, errorMessage(err))
1258 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001259 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001260
Earl Lee2e463fb2025-04-17 11:22:22 -07001261 // We do this as we go, but let's also do it at the end of the turn
1262 defer func() {
1263 if _, err := a.handleGitCommits(ctx); err != nil {
1264 // Just log the error, don't stop execution
1265 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1266 }
1267 }()
1268
Sean McCullougha1e0e492025-05-01 10:51:08 -07001269 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001270 resp := initialResp
1271 for {
1272 // Check if we are over budget
1273 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001274 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001275 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001276 }
1277
1278 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001279 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001280 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001281 break
1282 }
1283
Sean McCullough96b60dd2025-04-30 09:49:10 -07001284 // Transition to tool use requested state
1285 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1286
Sean McCullough885a16a2025-04-30 02:49:25 +00001287 // Handle tool execution
1288 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1289 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001290 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001291 }
1292
Sean McCullougha1e0e492025-05-01 10:51:08 -07001293 if toolResp == nil {
1294 return fmt.Errorf("cannot continue conversation with a nil tool response")
1295 }
1296
Sean McCullough885a16a2025-04-30 02:49:25 +00001297 // Set the response for the next iteration
1298 resp = toolResp
1299 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001300
1301 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001302}
1303
1304// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001305func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001306 // Wait for at least one message from the user
1307 msgs, err := a.GatherMessages(ctx, true)
1308 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001309 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001310 return nil, err
1311 }
1312
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001313 userMessage := llm.Message{
1314 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001315 Content: msgs,
1316 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001317
Sean McCullough96b60dd2025-04-30 09:49:10 -07001318 // Transition to sending to LLM state
1319 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1320
Sean McCullough885a16a2025-04-30 02:49:25 +00001321 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001322 resp, err := a.convo.SendMessage(userMessage)
1323 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001324 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001325 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001326 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001327 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001328
Sean McCullough96b60dd2025-04-30 09:49:10 -07001329 // Transition to processing LLM response state
1330 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1331
Sean McCullough885a16a2025-04-30 02:49:25 +00001332 return resp, nil
1333}
1334
1335// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001336func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1337 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001338 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001339 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001340
Sean McCullough96b60dd2025-04-30 09:49:10 -07001341 // Transition to checking for cancellation state
1342 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1343
Sean McCullough885a16a2025-04-30 02:49:25 +00001344 // Check if the operation was cancelled by the user
1345 select {
1346 case <-ctx.Done():
1347 // Don't actually run any of the tools, but rather build a response
1348 // for each tool_use message letting the LLM know that user canceled it.
1349 var err error
1350 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001351 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001352 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001353 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001354 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001355 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001356 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001357 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001358 // Transition to running tool state
1359 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1360
Sean McCullough885a16a2025-04-30 02:49:25 +00001361 // Add working directory to context for tool execution
1362 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1363
1364 // Execute the tools
1365 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001366 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001367 if ctx.Err() != nil { // e.g. the user canceled the operation
1368 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001369 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001370 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001371 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001372 a.pushToOutbox(ctx, errorMessage(err))
1373 }
1374 }
1375
1376 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001377 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001378 autoqualityMessages := a.processGitChanges(ctx)
1379
1380 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001381 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001382 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001383 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001384 return false, nil
1385 }
1386
1387 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001388 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1389 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001390}
1391
1392// processGitChanges checks for new git commits and runs autoformatters if needed
1393func (a *Agent) processGitChanges(ctx context.Context) []string {
1394 // Check for git commits after tool execution
1395 newCommits, err := a.handleGitCommits(ctx)
1396 if err != nil {
1397 // Just log the error, don't stop execution
1398 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1399 return nil
1400 }
1401
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001402 // Run mechanical checks if there was exactly one new commit.
1403 if len(newCommits) != 1 {
1404 return nil
1405 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001406 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001407 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1408 msg := a.codereview.RunMechanicalChecks(ctx)
1409 if msg != "" {
1410 a.pushToOutbox(ctx, AgentMessage{
1411 Type: AutoMessageType,
1412 Content: msg,
1413 Timestamp: time.Now(),
1414 })
1415 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001416 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001417
1418 return autoqualityMessages
1419}
1420
1421// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001422func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001423 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001424 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001425 msgs, err := a.GatherMessages(ctx, false)
1426 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001427 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001428 return false, nil
1429 }
1430
1431 // Inject any auto-generated messages from quality checks
1432 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001433 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001434 }
1435
1436 // Handle cancellation by appending a message about it
1437 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001438 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001439 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001440 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001441 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1442 } else if err := a.convo.OverBudget(); err != nil {
1443 // Handle budget issues by appending a message about it
1444 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 -07001445 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001446 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1447 }
1448
1449 // Combine tool results with user messages
1450 results = append(results, msgs...)
1451
1452 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001453 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001454 resp, err := a.convo.SendMessage(llm.Message{
1455 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001456 Content: results,
1457 })
1458 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001459 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001460 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1461 return true, nil // Return true to continue the conversation, but with no response
1462 }
1463
Sean McCullough96b60dd2025-04-30 09:49:10 -07001464 // Transition back to processing LLM response
1465 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1466
Sean McCullough885a16a2025-04-30 02:49:25 +00001467 if cancelled {
1468 return false, nil
1469 }
1470
1471 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001472}
1473
1474func (a *Agent) overBudget(ctx context.Context) error {
1475 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001476 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001477 m := budgetMessage(err)
1478 m.Content = m.Content + "\n\nBudget reset."
1479 a.pushToOutbox(ctx, budgetMessage(err))
1480 a.convo.ResetBudget(a.originalBudget)
1481 return err
1482 }
1483 return nil
1484}
1485
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001486func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001487 // Collect all text content
1488 var allText strings.Builder
1489 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001490 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001491 if allText.Len() > 0 {
1492 allText.WriteString("\n\n")
1493 }
1494 allText.WriteString(content.Text)
1495 }
1496 }
1497 return allText.String()
1498}
1499
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001500func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001501 a.mu.Lock()
1502 defer a.mu.Unlock()
1503 return a.convo.CumulativeUsage()
1504}
1505
Earl Lee2e463fb2025-04-17 11:22:22 -07001506// Diff returns a unified diff of changes made since the agent was instantiated.
1507func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001508 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001509 return "", fmt.Errorf("no initial commit reference available")
1510 }
1511
1512 // Find the repository root
1513 ctx := context.Background()
1514
1515 // If a specific commit hash is provided, show just that commit's changes
1516 if commit != nil && *commit != "" {
1517 // Validate that the commit looks like a valid git SHA
1518 if !isValidGitSHA(*commit) {
1519 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1520 }
1521
1522 // Get the diff for just this commit
1523 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1524 cmd.Dir = a.repoRoot
1525 output, err := cmd.CombinedOutput()
1526 if err != nil {
1527 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1528 }
1529 return string(output), nil
1530 }
1531
1532 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001533 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001534 cmd.Dir = a.repoRoot
1535 output, err := cmd.CombinedOutput()
1536 if err != nil {
1537 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1538 }
1539
1540 return string(output), nil
1541}
1542
Philip Zeyliger49edc922025-05-14 09:45:45 -07001543// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1544// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1545func (a *Agent) SketchGitBaseRef() string {
1546 if a.IsInContainer() {
1547 return "sketch-base"
1548 } else {
1549 return "sketch-base-" + a.SessionID()
1550 }
1551}
1552
1553// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1554func (a *Agent) SketchGitBase() string {
1555 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1556 cmd.Dir = a.repoRoot
1557 output, err := cmd.CombinedOutput()
1558 if err != nil {
1559 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1560 return "HEAD"
1561 }
1562 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001563}
1564
Pokey Rule7a113622025-05-12 10:58:45 +01001565// removeGitHooks removes the Git hooks directory from the repository
1566func removeGitHooks(_ context.Context, repoPath string) error {
1567 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1568
1569 // Check if hooks directory exists
1570 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1571 // Directory doesn't exist, nothing to do
1572 return nil
1573 }
1574
1575 // Remove the hooks directory
1576 err := os.RemoveAll(hooksDir)
1577 if err != nil {
1578 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1579 }
1580
1581 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001582 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001583 if err != nil {
1584 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1585 }
1586
1587 return nil
1588}
1589
Earl Lee2e463fb2025-04-17 11:22:22 -07001590// handleGitCommits() highlights new commits to the user. When running
1591// under docker, new HEADs are pushed to a branch according to the title.
1592func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1593 if a.repoRoot == "" {
1594 return nil, nil
1595 }
1596
1597 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1598 if err != nil {
1599 return nil, err
1600 }
1601 if head == a.lastHEAD {
1602 return nil, nil // nothing to do
1603 }
1604 defer func() {
1605 a.lastHEAD = head
1606 }()
1607
1608 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1609 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1610 // to the last 100 commits.
1611 var commits []*GitCommit
1612
1613 // Get commits since the initial commit
1614 // Format: <hash>\0<subject>\0<body>\0
1615 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1616 // Limit to 100 commits to avoid overwhelming the user
Philip Zeyliger49edc922025-05-14 09:45:45 -07001617 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 -07001618 cmd.Dir = a.repoRoot
1619 output, err := cmd.Output()
1620 if err != nil {
1621 return nil, fmt.Errorf("failed to get git log: %w", err)
1622 }
1623
1624 // Parse git log output and filter out already seen commits
1625 parsedCommits := parseGitLog(string(output))
1626
1627 var headCommit *GitCommit
1628
1629 // Filter out commits we've already seen
1630 for _, commit := range parsedCommits {
1631 if commit.Hash == head {
1632 headCommit = &commit
1633 }
1634
1635 // Skip if we've seen this commit before. If our head has changed, always include that.
1636 if a.seenCommits[commit.Hash] && commit.Hash != head {
1637 continue
1638 }
1639
1640 // Mark this commit as seen
1641 a.seenCommits[commit.Hash] = true
1642
1643 // Add to our list of new commits
1644 commits = append(commits, &commit)
1645 }
1646
1647 if a.gitRemoteAddr != "" {
1648 if headCommit == nil {
1649 // I think this can only happen if we have a bug or if there's a race.
1650 headCommit = &GitCommit{}
1651 headCommit.Hash = head
1652 headCommit.Subject = "unknown"
1653 commits = append(commits, headCommit)
1654 }
1655
Philip Zeyliger113e2052025-05-09 21:59:40 +00001656 originalBranch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
1657 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001658
1659 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1660 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1661 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001662
1663 // Try up to 10 times with different branch names if the branch is checked out on the remote
1664 var out []byte
1665 var err error
1666 for retries := range 10 {
1667 if retries > 0 {
1668 // Add a numeric suffix to the branch name
1669 branch = fmt.Sprintf("%s%d", originalBranch, retries)
1670 }
1671
1672 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1673 cmd.Dir = a.workingDir
1674 out, err = cmd.CombinedOutput()
1675
1676 if err == nil {
1677 // Success! Break out of the retry loop
1678 break
1679 }
1680
1681 // Check if this is the "refusing to update checked out branch" error
1682 if !strings.Contains(string(out), "refusing to update checked out branch") {
1683 // This is a different error, so don't retry
1684 break
1685 }
1686
1687 // If we're on the last retry, we'll report the error
1688 if retries == 9 {
1689 break
1690 }
1691 }
1692
1693 if err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001694 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1695 } else {
1696 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001697 // Update the agent's branch name if we ended up using a different one
1698 if branch != originalBranch {
1699 a.branchName = branch
1700 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001701 }
1702 }
1703
1704 // If we found new commits, create a message
1705 if len(commits) > 0 {
1706 msg := AgentMessage{
1707 Type: CommitMessageType,
1708 Timestamp: time.Now(),
1709 Commits: commits,
1710 }
1711 a.pushToOutbox(ctx, msg)
1712 }
1713 return commits, nil
1714}
1715
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001716func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001717 return strings.Map(func(r rune) rune {
1718 // lowercase
1719 if r >= 'A' && r <= 'Z' {
1720 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001721 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001722 // replace spaces with dashes
1723 if r == ' ' {
1724 return '-'
1725 }
1726 // allow alphanumerics and dashes
1727 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1728 return r
1729 }
1730 return -1
1731 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001732}
1733
1734// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1735// and returns an array of GitCommit structs.
1736func parseGitLog(output string) []GitCommit {
1737 var commits []GitCommit
1738
1739 // No output means no commits
1740 if len(output) == 0 {
1741 return commits
1742 }
1743
1744 // Split by NULL byte
1745 parts := strings.Split(output, "\x00")
1746
1747 // Process in triplets (hash, subject, body)
1748 for i := 0; i < len(parts); i++ {
1749 // Skip empty parts
1750 if parts[i] == "" {
1751 continue
1752 }
1753
1754 // This should be a hash
1755 hash := strings.TrimSpace(parts[i])
1756
1757 // Make sure we have at least a subject part available
1758 if i+1 >= len(parts) {
1759 break // No more parts available
1760 }
1761
1762 // Get the subject
1763 subject := strings.TrimSpace(parts[i+1])
1764
1765 // Get the body if available
1766 body := ""
1767 if i+2 < len(parts) {
1768 body = strings.TrimSpace(parts[i+2])
1769 }
1770
1771 // Skip to the next triplet
1772 i += 2
1773
1774 commits = append(commits, GitCommit{
1775 Hash: hash,
1776 Subject: subject,
1777 Body: body,
1778 })
1779 }
1780
1781 return commits
1782}
1783
1784func repoRoot(ctx context.Context, dir string) (string, error) {
1785 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1786 stderr := new(strings.Builder)
1787 cmd.Stderr = stderr
1788 cmd.Dir = dir
1789 out, err := cmd.Output()
1790 if err != nil {
1791 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1792 }
1793 return strings.TrimSpace(string(out)), nil
1794}
1795
1796func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1797 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1798 stderr := new(strings.Builder)
1799 cmd.Stderr = stderr
1800 cmd.Dir = dir
1801 out, err := cmd.Output()
1802 if err != nil {
1803 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1804 }
1805 // TODO: validate that out is valid hex
1806 return strings.TrimSpace(string(out)), nil
1807}
1808
1809// isValidGitSHA validates if a string looks like a valid git SHA hash.
1810// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1811func isValidGitSHA(sha string) bool {
1812 // Git SHA must be a hexadecimal string with at least 4 characters
1813 if len(sha) < 4 || len(sha) > 40 {
1814 return false
1815 }
1816
1817 // Check if the string only contains hexadecimal characters
1818 for _, char := range sha {
1819 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1820 return false
1821 }
1822 }
1823
1824 return true
1825}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001826
1827// getGitOrigin returns the URL of the git remote 'origin' if it exists
1828func getGitOrigin(ctx context.Context, dir string) string {
1829 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1830 cmd.Dir = dir
1831 stderr := new(strings.Builder)
1832 cmd.Stderr = stderr
1833 out, err := cmd.Output()
1834 if err != nil {
1835 return ""
1836 }
1837 return strings.TrimSpace(string(out))
1838}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001839
1840func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1841 cmd := exec.CommandContext(ctx, "git", "stash")
1842 cmd.Dir = workingDir
1843 if out, err := cmd.CombinedOutput(); err != nil {
1844 return fmt.Errorf("git stash: %s: %v", out, err)
1845 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001846 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001847 cmd.Dir = workingDir
1848 if out, err := cmd.CombinedOutput(); err != nil {
1849 return fmt.Errorf("git fetch: %s: %w", out, err)
1850 }
1851 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1852 cmd.Dir = workingDir
1853 if out, err := cmd.CombinedOutput(); err != nil {
1854 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1855 }
1856 a.lastHEAD = revision
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001857 return nil
1858}
1859
1860func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1861 a.mu.Lock()
1862 a.title = ""
1863 a.firstMessageIndex = len(a.history)
1864 a.convo = a.initConvo()
1865 gitReset := func() error {
1866 if a.config.InDocker && rev != "" {
1867 err := a.initGitRevision(ctx, a.workingDir, rev)
1868 if err != nil {
1869 return err
1870 }
1871 } else if !a.config.InDocker && rev != "" {
1872 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1873 }
1874 return nil
1875 }
1876 err := gitReset()
1877 a.mu.Unlock()
1878 if err != nil {
1879 a.pushToOutbox(a.config.Context, errorMessage(err))
1880 }
1881
1882 a.pushToOutbox(a.config.Context, AgentMessage{
1883 Type: AgentMessageType, Content: "Conversation restarted.",
1884 })
1885 if initialPrompt != "" {
1886 a.UserMessage(ctx, initialPrompt)
1887 }
1888 return nil
1889}
1890
1891func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1892 msg := `The user has requested a suggestion for a re-prompt.
1893
1894 Given the current conversation thus far, suggest a re-prompt that would
1895 capture the instructions and feedback so far, as well as any
1896 research or other information that would be helpful in implementing
1897 the task.
1898
1899 Reply with ONLY the reprompt text.
1900 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001901 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001902 // By doing this in a subconversation, the agent doesn't call tools (because
1903 // there aren't any), and there's not a concurrency risk with on-going other
1904 // outstanding conversations.
1905 convo := a.convo.SubConvoWithHistory()
1906 resp, err := convo.SendMessage(userMessage)
1907 if err != nil {
1908 a.pushToOutbox(ctx, errorMessage(err))
1909 return "", err
1910 }
1911 textContent := collectTextContent(resp)
1912 return textContent, nil
1913}
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001914
1915// systemPromptData contains the data used to render the system prompt template
1916type systemPromptData struct {
1917 EditPrompt string
1918 ClientGOOS string
1919 ClientGOARCH string
1920 WorkingDir string
1921 RepoRoot string
1922 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001923 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001924}
1925
1926// renderSystemPrompt renders the system prompt template.
1927func (a *Agent) renderSystemPrompt() string {
1928 // Determine the appropriate edit prompt based on config
1929 var editPrompt string
1930 if a.config.UseAnthropicEdit {
1931 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."
1932 } else {
1933 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1934 }
1935
1936 data := systemPromptData{
1937 EditPrompt: editPrompt,
1938 ClientGOOS: a.config.ClientGOOS,
1939 ClientGOARCH: a.config.ClientGOARCH,
1940 WorkingDir: a.workingDir,
1941 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07001942 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001943 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001944 }
1945
1946 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1947 if err != nil {
1948 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1949 }
1950 buf := new(strings.Builder)
1951 err = tmpl.Execute(buf, data)
1952 if err != nil {
1953 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1954 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001955 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001956 return buf.String()
1957}
Philip Zeyligereab12de2025-05-14 02:35:53 +00001958
1959// StateTransitionIterator provides an iterator over state transitions.
1960type StateTransitionIterator interface {
1961 // Next blocks until a new state transition is available or context is done.
1962 // Returns nil if the context is cancelled.
1963 Next() *StateTransition
1964 // Close removes the listener and cleans up resources.
1965 Close()
1966}
1967
1968// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
1969type StateTransitionIteratorImpl struct {
1970 agent *Agent
1971 ctx context.Context
1972 ch chan StateTransition
1973 unsubscribe func()
1974}
1975
1976// Next blocks until a new state transition is available or the context is cancelled.
1977func (s *StateTransitionIteratorImpl) Next() *StateTransition {
1978 select {
1979 case <-s.ctx.Done():
1980 return nil
1981 case transition, ok := <-s.ch:
1982 if !ok {
1983 return nil
1984 }
1985 transitionCopy := transition
1986 return &transitionCopy
1987 }
1988}
1989
1990// Close removes the listener and cleans up resources.
1991func (s *StateTransitionIteratorImpl) Close() {
1992 if s.unsubscribe != nil {
1993 s.unsubscribe()
1994 s.unsubscribe = nil
1995 }
1996}
1997
1998// NewStateTransitionIterator returns an iterator that receives state transitions.
1999func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2000 a.mu.Lock()
2001 defer a.mu.Unlock()
2002
2003 // Create channel to receive state transitions
2004 ch := make(chan StateTransition, 10)
2005
2006 // Add a listener to the state machine
2007 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2008
2009 return &StateTransitionIteratorImpl{
2010 agent: a,
2011 ctx: ctx,
2012 ch: ch,
2013 unsubscribe: unsubscribe,
2014 }
2015}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002016
2017// setupGitHooks creates or updates git hooks in the specified working directory.
2018func setupGitHooks(workingDir string) error {
2019 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2020
2021 _, err := os.Stat(hooksDir)
2022 if os.IsNotExist(err) {
2023 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2024 }
2025 if err != nil {
2026 return fmt.Errorf("error checking git hooks directory: %w", err)
2027 }
2028
2029 // Define the post-commit hook content
2030 postCommitHook := `#!/bin/bash
2031echo "<post_commit_hook>"
2032echo "Please review this commit message and fix it if it is incorrect."
2033echo "This hook only echos the commit message; it does not modify it."
2034echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2035echo "<last_commit_message>"
2036git log -1 --pretty=%B
2037echo "</last_commit_message>"
2038echo "</post_commit_hook>"
2039`
2040
2041 // Define the prepare-commit-msg hook content
2042 prepareCommitMsgHook := `#!/bin/bash
2043# Add Co-Authored-By and Change-ID trailers to commit messages
2044# Check if these trailers already exist before adding them
2045
2046commit_file="$1"
2047COMMIT_SOURCE="$2"
2048
2049# Skip for merges, squashes, or when using a commit template
2050if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2051 [ "$COMMIT_SOURCE" = "squash" ]; then
2052 exit 0
2053fi
2054
2055commit_msg=$(cat "$commit_file")
2056
2057needs_co_author=true
2058needs_change_id=true
2059
2060# Check if commit message already has Co-Authored-By trailer
2061if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2062 needs_co_author=false
2063fi
2064
2065# Check if commit message already has Change-ID trailer
2066if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2067 needs_change_id=false
2068fi
2069
2070# Only modify if at least one trailer needs to be added
2071if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
2072 # Ensure there's a blank line before trailers
2073 if [ -s "$commit_file" ] && [ "$(tail -1 "$commit_file" | tr -d '\n')" != "" ]; then
2074 echo "" >> "$commit_file"
2075 fi
2076
2077 # Add trailers if needed
2078 if [ "$needs_co_author" = true ]; then
2079 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2080 fi
2081
2082 if [ "$needs_change_id" = true ]; then
2083 change_id=$(openssl rand -hex 8)
2084 echo "Change-ID: s${change_id}k" >> "$commit_file"
2085 fi
2086fi
2087`
2088
2089 // Update or create the post-commit hook
2090 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2091 if err != nil {
2092 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2093 }
2094
2095 // Update or create the prepare-commit-msg hook
2096 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2097 if err != nil {
2098 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2099 }
2100
2101 return nil
2102}
2103
2104// updateOrCreateHook creates a new hook file or updates an existing one
2105// by appending the new content if it doesn't already contain it.
2106func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2107 // Check if the hook already exists
2108 buf, err := os.ReadFile(hookPath)
2109 if os.IsNotExist(err) {
2110 // Hook doesn't exist, create it
2111 err = os.WriteFile(hookPath, []byte(content), 0o755)
2112 if err != nil {
2113 return fmt.Errorf("failed to create hook: %w", err)
2114 }
2115 return nil
2116 }
2117 if err != nil {
2118 return fmt.Errorf("error reading existing hook: %w", err)
2119 }
2120
2121 // Hook exists, check if our content is already in it by looking for a distinctive line
2122 code := string(buf)
2123 if strings.Contains(code, distinctiveLine) {
2124 // Already contains our content, nothing to do
2125 return nil
2126 }
2127
2128 // Append our content to the existing hook
2129 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2130 if err != nil {
2131 return fmt.Errorf("failed to open hook for appending: %w", err)
2132 }
2133 defer f.Close()
2134
2135 // Ensure there's a newline at the end of the existing content if needed
2136 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2137 _, err = f.WriteString("\n")
2138 if err != nil {
2139 return fmt.Errorf("failed to add newline to hook: %w", err)
2140 }
2141 }
2142
2143 // Add a separator before our content
2144 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2145 if err != nil {
2146 return fmt.Errorf("failed to append to hook: %w", err)
2147 }
2148
2149 return nil
2150}