blob: 95a32bb8467840c9342a61bf9b31d695cc2a7ff0 [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"
14 "runtime/debug"
15 "slices"
16 "strings"
17 "sync"
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +000018 "text/template"
Earl Lee2e463fb2025-04-17 11:22:22 -070019 "time"
20
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000021 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070022 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000023 "sketch.dev/claudetool/bashkit"
Autoformatter4962f152025-05-06 17:24:20 +000024 "sketch.dev/claudetool/browse"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000025 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydera997be62025-05-07 22:52:46 +000026 "sketch.dev/claudetool/onstart"
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -070027 "sketch.dev/experiment"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070028 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070029 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070030 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070031)
32
33const (
34 userCancelMessage = "user requested agent to stop handling responses"
35)
36
Philip Zeyligerb7c58752025-05-01 10:10:17 -070037type MessageIterator interface {
38 // Next blocks until the next message is available. It may
39 // return nil if the underlying iterator context is done.
40 Next() *AgentMessage
41 Close()
42}
43
Earl Lee2e463fb2025-04-17 11:22:22 -070044type CodingAgent interface {
45 // Init initializes an agent inside a docker container.
46 Init(AgentInit) error
47
48 // Ready returns a channel closed after Init successfully called.
49 Ready() <-chan struct{}
50
51 // URL reports the HTTP URL of this agent.
52 URL() string
53
54 // UserMessage enqueues a message to the agent and returns immediately.
55 UserMessage(ctx context.Context, msg string)
56
Philip Zeyligerb7c58752025-05-01 10:10:17 -070057 // Returns an iterator that finishes when the context is done and
58 // starts with the given message index.
59 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070060
61 // Loop begins the agent loop returns only when ctx is cancelled.
62 Loop(ctx context.Context)
63
Sean McCulloughedc88dc2025-04-30 02:55:01 +000064 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070065
66 CancelToolUse(toolUseID string, cause error) error
67
68 // Returns a subset of the agent's message history.
69 Messages(start int, end int) []AgentMessage
70
71 // Returns the current number of messages in the history
72 MessageCount() int
73
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070074 TotalUsage() conversation.CumulativeUsage
75 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070076
Earl Lee2e463fb2025-04-17 11:22:22 -070077 WorkingDir() string
78
79 // Diff returns a unified diff of changes made since the agent was instantiated.
80 // If commit is non-nil, it shows the diff for just that specific commit.
81 Diff(commit *string) (string, error)
82
83 // InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
84 InitialCommit() string
85
86 // Title returns the current title of the conversation.
87 Title() string
88
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000089 // BranchName returns the git branch name for the conversation.
90 BranchName() string
91
Earl Lee2e463fb2025-04-17 11:22:22 -070092 // OS returns the operating system of the client.
93 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +000094
Philip Zeyligerc72fff52025-04-29 20:17:54 +000095 // SessionID returns the unique session identifier.
96 SessionID() string
97
Philip Zeyliger99a9a022025-04-27 15:15:25 +000098 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
99 OutstandingLLMCallCount() int
100
101 // OutstandingToolCalls returns the names of outstanding tool calls.
102 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000103 OutsideOS() string
104 OutsideHostname() string
105 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000106 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000107 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
108 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700109
110 // RestartConversation resets the conversation history
111 RestartConversation(ctx context.Context, rev string, initialPrompt string) error
112 // SuggestReprompt suggests a re-prompt based on the current conversation.
113 SuggestReprompt(ctx context.Context) (string, error)
114 // IsInContainer returns true if the agent is running in a container
115 IsInContainer() bool
116 // FirstMessageIndex returns the index of the first message in the current conversation
117 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700118
119 CurrentStateName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700120}
121
122type CodingAgentMessageType string
123
124const (
125 UserMessageType CodingAgentMessageType = "user"
126 AgentMessageType CodingAgentMessageType = "agent"
127 ErrorMessageType CodingAgentMessageType = "error"
128 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
129 ToolUseMessageType CodingAgentMessageType = "tool"
130 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
131 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
132
133 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
134)
135
136type AgentMessage struct {
137 Type CodingAgentMessageType `json:"type"`
138 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
139 EndOfTurn bool `json:"end_of_turn"`
140
141 Content string `json:"content"`
142 ToolName string `json:"tool_name,omitempty"`
143 ToolInput string `json:"input,omitempty"`
144 ToolResult string `json:"tool_result,omitempty"`
145 ToolError bool `json:"tool_error,omitempty"`
146 ToolCallId string `json:"tool_call_id,omitempty"`
147
148 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
149 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
150
Sean McCulloughd9f13372025-04-21 15:08:49 -0700151 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
152 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
153
Earl Lee2e463fb2025-04-17 11:22:22 -0700154 // Commits is a list of git commits for a commit message
155 Commits []*GitCommit `json:"commits,omitempty"`
156
157 Timestamp time.Time `json:"timestamp"`
158 ConversationID string `json:"conversation_id"`
159 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700160 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700161
162 // Message timing information
163 StartTime *time.Time `json:"start_time,omitempty"`
164 EndTime *time.Time `json:"end_time,omitempty"`
165 Elapsed *time.Duration `json:"elapsed,omitempty"`
166
167 // Turn duration - the time taken for a complete agent turn
168 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
169
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000170 // HideOutput indicates that this message should not be rendered in the UI.
171 // This is useful for subconversations that generate output that shouldn't be shown to the user.
172 HideOutput bool `json:"hide_output,omitempty"`
173
Earl Lee2e463fb2025-04-17 11:22:22 -0700174 Idx int `json:"idx"`
175}
176
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000177// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700178func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700179 if convo == nil {
180 m.ConversationID = ""
181 m.ParentConversationID = nil
182 return
183 }
184 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000185 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700186 if convo.Parent != nil {
187 m.ParentConversationID = &convo.Parent.ID
188 }
189}
190
Earl Lee2e463fb2025-04-17 11:22:22 -0700191// GitCommit represents a single git commit for a commit message
192type GitCommit struct {
193 Hash string `json:"hash"` // Full commit hash
194 Subject string `json:"subject"` // Commit subject line
195 Body string `json:"body"` // Full commit message body
196 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
197}
198
199// ToolCall represents a single tool call within an agent message
200type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700201 Name string `json:"name"`
202 Input string `json:"input"`
203 ToolCallId string `json:"tool_call_id"`
204 ResultMessage *AgentMessage `json:"result_message,omitempty"`
205 Args string `json:"args,omitempty"`
206 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700207}
208
209func (a *AgentMessage) Attr() slog.Attr {
210 var attrs []any = []any{
211 slog.String("type", string(a.Type)),
212 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700213 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700214 if a.EndOfTurn {
215 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
216 }
217 if a.Content != "" {
218 attrs = append(attrs, slog.String("content", a.Content))
219 }
220 if a.ToolName != "" {
221 attrs = append(attrs, slog.String("tool_name", a.ToolName))
222 }
223 if a.ToolInput != "" {
224 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
225 }
226 if a.Elapsed != nil {
227 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
228 }
229 if a.TurnDuration != nil {
230 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
231 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700232 if len(a.ToolResult) > 0 {
233 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700234 }
235 if a.ToolError {
236 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
237 }
238 if len(a.ToolCalls) > 0 {
239 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
240 for i, tc := range a.ToolCalls {
241 toolCallAttrs = append(toolCallAttrs, slog.Group(
242 fmt.Sprintf("tool_call_%d", i),
243 slog.String("name", tc.Name),
244 slog.String("input", tc.Input),
245 ))
246 }
247 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
248 }
249 if a.ConversationID != "" {
250 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
251 }
252 if a.ParentConversationID != nil {
253 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
254 }
255 if a.Usage != nil && !a.Usage.IsZero() {
256 attrs = append(attrs, a.Usage.Attr())
257 }
258 // TODO: timestamp, convo ids, idx?
259 return slog.Group("agent_message", attrs...)
260}
261
262func errorMessage(err error) AgentMessage {
263 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
264 if os.Getenv(("DEBUG")) == "1" {
265 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
266 }
267
268 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
269}
270
271func budgetMessage(err error) AgentMessage {
272 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
273}
274
275// ConvoInterface defines the interface for conversation interactions
276type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700277 CumulativeUsage() conversation.CumulativeUsage
278 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700279 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700280 SendMessage(message llm.Message) (*llm.Response, error)
281 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700282 GetID() string
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700283 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error)
284 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700285 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700286 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700287}
288
289type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700290 convo ConvoInterface
291 config AgentConfig // config for this agent
292 workingDir string
293 repoRoot string // workingDir may be a subdir of repoRoot
294 url string
295 firstMessageIndex int // index of the first message in the current conversation
296 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
297 initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
298 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000299 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700300 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000301 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700302 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700303 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700304 title string
305 branchName string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000306 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700307 // State machine to track agent state
308 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000309 // Outside information
310 outsideHostname string
311 outsideOS string
312 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000313 // URL of the git remote 'origin' if it exists
314 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700315
316 // Time when the current turn started (reset at the beginning of InnerLoop)
317 startOfTurn time.Time
318
319 // Inbox - for messages from the user to the agent.
320 // sent on by UserMessage
321 // . e.g. when user types into the chat textarea
322 // read from by GatherMessages
323 inbox chan string
324
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000325 // protects cancelTurn
326 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700327 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000328 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700329
330 // protects following
331 mu sync.Mutex
332
333 // Stores all messages for this agent
334 history []AgentMessage
335
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700336 // Iterators add themselves here when they're ready to be notified of new messages.
337 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700338
339 // Track git commits we've already seen (by hash)
340 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000341
342 // Track outstanding LLM call IDs
343 outstandingLLMCalls map[string]struct{}
344
345 // Track outstanding tool calls by ID with their names
346 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700347}
348
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700349// NewIterator implements CodingAgent.
350func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
351 a.mu.Lock()
352 defer a.mu.Unlock()
353
354 return &MessageIteratorImpl{
355 agent: a,
356 ctx: ctx,
357 nextMessageIdx: nextMessageIdx,
358 ch: make(chan *AgentMessage, 100),
359 }
360}
361
362type MessageIteratorImpl struct {
363 agent *Agent
364 ctx context.Context
365 nextMessageIdx int
366 ch chan *AgentMessage
367 subscribed bool
368}
369
370func (m *MessageIteratorImpl) Close() {
371 m.agent.mu.Lock()
372 defer m.agent.mu.Unlock()
373 // Delete ourselves from the subscribers list
374 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
375 return x == m.ch
376 })
377 close(m.ch)
378}
379
380func (m *MessageIteratorImpl) Next() *AgentMessage {
381 // We avoid subscription at creation to let ourselves catch up to "current state"
382 // before subscribing.
383 if !m.subscribed {
384 m.agent.mu.Lock()
385 if m.nextMessageIdx < len(m.agent.history) {
386 msg := &m.agent.history[m.nextMessageIdx]
387 m.nextMessageIdx++
388 m.agent.mu.Unlock()
389 return msg
390 }
391 // The next message doesn't exist yet, so let's subscribe
392 m.agent.subscribers = append(m.agent.subscribers, m.ch)
393 m.subscribed = true
394 m.agent.mu.Unlock()
395 }
396
397 for {
398 select {
399 case <-m.ctx.Done():
400 m.agent.mu.Lock()
401 // Delete ourselves from the subscribers list
402 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
403 return x == m.ch
404 })
405 m.subscribed = false
406 m.agent.mu.Unlock()
407 return nil
408 case msg, ok := <-m.ch:
409 if !ok {
410 // Close may have been called
411 return nil
412 }
413 if msg.Idx == m.nextMessageIdx {
414 m.nextMessageIdx++
415 return msg
416 }
417 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
418 panic("out of order message")
419 }
420 }
421}
422
Sean McCulloughd9d45812025-04-30 16:53:41 -0700423// Assert that Agent satisfies the CodingAgent interface.
424var _ CodingAgent = &Agent{}
425
426// StateName implements CodingAgent.
427func (a *Agent) CurrentStateName() string {
428 if a.stateMachine == nil {
429 return ""
430 }
431 return a.stateMachine.currentState.String()
432}
433
Earl Lee2e463fb2025-04-17 11:22:22 -0700434func (a *Agent) URL() string { return a.url }
435
436// Title returns the current title of the conversation.
437// If no title has been set, returns an empty string.
438func (a *Agent) Title() string {
439 a.mu.Lock()
440 defer a.mu.Unlock()
441 return a.title
442}
443
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000444// BranchName returns the git branch name for the conversation.
445func (a *Agent) BranchName() string {
446 a.mu.Lock()
447 defer a.mu.Unlock()
448 return a.branchName
449}
450
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000451// OutstandingLLMCallCount returns the number of outstanding LLM calls.
452func (a *Agent) OutstandingLLMCallCount() int {
453 a.mu.Lock()
454 defer a.mu.Unlock()
455 return len(a.outstandingLLMCalls)
456}
457
458// OutstandingToolCalls returns the names of outstanding tool calls.
459func (a *Agent) OutstandingToolCalls() []string {
460 a.mu.Lock()
461 defer a.mu.Unlock()
462
463 tools := make([]string, 0, len(a.outstandingToolCalls))
464 for _, toolName := range a.outstandingToolCalls {
465 tools = append(tools, toolName)
466 }
467 return tools
468}
469
Earl Lee2e463fb2025-04-17 11:22:22 -0700470// OS returns the operating system of the client.
471func (a *Agent) OS() string {
472 return a.config.ClientGOOS
473}
474
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000475func (a *Agent) SessionID() string {
476 return a.config.SessionID
477}
478
Philip Zeyliger18532b22025-04-23 21:11:46 +0000479// OutsideOS returns the operating system of the outside system.
480func (a *Agent) OutsideOS() string {
481 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000482}
483
Philip Zeyliger18532b22025-04-23 21:11:46 +0000484// OutsideHostname returns the hostname of the outside system.
485func (a *Agent) OutsideHostname() string {
486 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000487}
488
Philip Zeyliger18532b22025-04-23 21:11:46 +0000489// OutsideWorkingDir returns the working directory on the outside system.
490func (a *Agent) OutsideWorkingDir() string {
491 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000492}
493
494// GitOrigin returns the URL of the git remote 'origin' if it exists.
495func (a *Agent) GitOrigin() string {
496 return a.gitOrigin
497}
498
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000499func (a *Agent) OpenBrowser(url string) {
500 if !a.IsInContainer() {
501 browser.Open(url)
502 return
503 }
504 // We're in Docker, need to send a request to the Git server
505 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700506 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000507 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700508 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000509 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700510 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000511 return
512 }
513 defer resp.Body.Close()
514 if resp.StatusCode == http.StatusOK {
515 return
516 }
517 body, _ := io.ReadAll(resp.Body)
518 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
519}
520
Sean McCullough96b60dd2025-04-30 09:49:10 -0700521// CurrentState returns the current state of the agent's state machine.
522func (a *Agent) CurrentState() State {
523 return a.stateMachine.CurrentState()
524}
525
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700526func (a *Agent) IsInContainer() bool {
527 return a.config.InDocker
528}
529
530func (a *Agent) FirstMessageIndex() int {
531 a.mu.Lock()
532 defer a.mu.Unlock()
533 return a.firstMessageIndex
534}
535
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000536// SetTitle sets the title of the conversation.
537func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700538 a.mu.Lock()
539 defer a.mu.Unlock()
540 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000541}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700542
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000543// SetBranch sets the branch name of the conversation.
544func (a *Agent) SetBranch(branchName string) {
545 a.mu.Lock()
546 defer a.mu.Unlock()
547 a.branchName = branchName
Earl Lee2e463fb2025-04-17 11:22:22 -0700548}
549
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000550// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700551func (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 +0000552 // Track the tool call
553 a.mu.Lock()
554 a.outstandingToolCalls[id] = toolName
555 a.mu.Unlock()
556}
557
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700558// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
559// If there's only one element in the array and it's a text type, it returns that text directly.
560// It also processes nested ToolResult arrays recursively.
561func contentToString(contents []llm.Content) string {
562 if len(contents) == 0 {
563 return ""
564 }
565
566 // If there's only one element and it's a text type, return it directly
567 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
568 return contents[0].Text
569 }
570
571 // Otherwise, concatenate all text content
572 var result strings.Builder
573 for _, content := range contents {
574 if content.Type == llm.ContentTypeText {
575 result.WriteString(content.Text)
576 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
577 // Recursively process nested tool results
578 result.WriteString(contentToString(content.ToolResult))
579 }
580 }
581
582 return result.String()
583}
584
Earl Lee2e463fb2025-04-17 11:22:22 -0700585// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700586func (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 +0000587 // Remove the tool call from outstanding calls
588 a.mu.Lock()
589 delete(a.outstandingToolCalls, toolID)
590 a.mu.Unlock()
591
Earl Lee2e463fb2025-04-17 11:22:22 -0700592 m := AgentMessage{
593 Type: ToolUseMessageType,
594 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700595 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700596 ToolError: content.ToolError,
597 ToolName: toolName,
598 ToolInput: string(toolInput),
599 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700600 StartTime: content.ToolUseStartTime,
601 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700602 }
603
604 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700605 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
606 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700607 m.Elapsed = &elapsed
608 }
609
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700610 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700611 a.pushToOutbox(ctx, m)
612}
613
614// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700615func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000616 a.mu.Lock()
617 defer a.mu.Unlock()
618 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700619 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
620}
621
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700622// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700623// that need to be displayed (as well as tool calls that we send along when
624// they're done). (It would be reasonable to also mention tool calls when they're
625// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700626func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000627 // Remove the LLM call from outstanding calls
628 a.mu.Lock()
629 delete(a.outstandingLLMCalls, id)
630 a.mu.Unlock()
631
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700632 if resp == nil {
633 // LLM API call failed
634 m := AgentMessage{
635 Type: ErrorMessageType,
636 Content: "API call failed, type 'continue' to try again",
637 }
638 m.SetConvo(convo)
639 a.pushToOutbox(ctx, m)
640 return
641 }
642
Earl Lee2e463fb2025-04-17 11:22:22 -0700643 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700644 if convo.Parent == nil { // subconvos never end the turn
645 switch resp.StopReason {
646 case llm.StopReasonToolUse:
647 // Check whether any of the tool calls are for tools that should end the turn
648 ToolSearch:
649 for _, part := range resp.Content {
650 if part.Type != llm.ContentTypeToolUse {
651 continue
652 }
Sean McCullough021557a2025-05-05 23:20:53 +0000653 // Find the tool by name
654 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700655 if tool.Name == part.ToolName {
656 endOfTurn = tool.EndsTurn
657 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000658 }
659 }
Sean McCullough021557a2025-05-05 23:20:53 +0000660 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700661 default:
662 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000663 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700664 }
665 m := AgentMessage{
666 Type: AgentMessageType,
667 Content: collectTextContent(resp),
668 EndOfTurn: endOfTurn,
669 Usage: &resp.Usage,
670 StartTime: resp.StartTime,
671 EndTime: resp.EndTime,
672 }
673
674 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700675 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700676 var toolCalls []ToolCall
677 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700678 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700679 toolCalls = append(toolCalls, ToolCall{
680 Name: part.ToolName,
681 Input: string(part.ToolInput),
682 ToolCallId: part.ID,
683 })
684 }
685 }
686 m.ToolCalls = toolCalls
687 }
688
689 // Calculate the elapsed time if both start and end times are set
690 if resp.StartTime != nil && resp.EndTime != nil {
691 elapsed := resp.EndTime.Sub(*resp.StartTime)
692 m.Elapsed = &elapsed
693 }
694
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700695 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700696 a.pushToOutbox(ctx, m)
697}
698
699// WorkingDir implements CodingAgent.
700func (a *Agent) WorkingDir() string {
701 return a.workingDir
702}
703
704// MessageCount implements CodingAgent.
705func (a *Agent) MessageCount() int {
706 a.mu.Lock()
707 defer a.mu.Unlock()
708 return len(a.history)
709}
710
711// Messages implements CodingAgent.
712func (a *Agent) Messages(start int, end int) []AgentMessage {
713 a.mu.Lock()
714 defer a.mu.Unlock()
715 return slices.Clone(a.history[start:end])
716}
717
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700718func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700719 return a.originalBudget
720}
721
722// AgentConfig contains configuration for creating a new Agent.
723type AgentConfig struct {
724 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700725 Service llm.Service
726 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700727 GitUsername string
728 GitEmail string
729 SessionID string
730 ClientGOOS string
731 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700732 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700733 UseAnthropicEdit bool
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000734 OneShot bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000735 // Outside information
736 OutsideHostname string
737 OutsideOS string
738 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700739}
740
741// NewAgent creates a new Agent.
742// It is not usable until Init() is called.
743func NewAgent(config AgentConfig) *Agent {
744 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000745 config: config,
746 ready: make(chan struct{}),
747 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700748 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000749 startedAt: time.Now(),
750 originalBudget: config.Budget,
751 seenCommits: make(map[string]bool),
752 outsideHostname: config.OutsideHostname,
753 outsideOS: config.OutsideOS,
754 outsideWorkingDir: config.OutsideWorkingDir,
755 outstandingLLMCalls: make(map[string]struct{}),
756 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700757 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700758 }
759 return agent
760}
761
762type AgentInit struct {
763 WorkingDir string
764 NoGit bool // only for testing
765
766 InDocker bool
767 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000768 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700769 GitRemoteAddr string
770 HostAddr string
771}
772
773func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700774 if a.convo != nil {
775 return fmt.Errorf("Agent.Init: already initialized")
776 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700777 ctx := a.config.Context
778 if ini.InDocker {
779 cmd := exec.CommandContext(ctx, "git", "stash")
780 cmd.Dir = ini.WorkingDir
781 if out, err := cmd.CombinedOutput(); err != nil {
782 return fmt.Errorf("git stash: %s: %v", out, err)
783 }
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700784 // sketch-host is a git repo hosted by "outtie sketch". When it notices a 'git fetch',
785 // it runs "git fetch" underneath the covers to get its latest commits. By configuring
786 // an additional remote.sketch-host.fetch, we make "origin/main" on innie sketch look like
787 // origin/main on outtie sketch, which should make it easier to rebase.
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700788 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
789 cmd.Dir = ini.WorkingDir
790 if out, err := cmd.CombinedOutput(); err != nil {
791 return fmt.Errorf("git remote add: %s: %v", out, err)
792 }
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700793 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.sketch-host.fetch",
794 "+refs/heads/feature/*:refs/remotes/origin/feature/*")
795 cmd.Dir = ini.WorkingDir
796 if out, err := cmd.CombinedOutput(); err != nil {
797 return fmt.Errorf("git config --add: %s: %v", out, err)
798 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000799 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700800 cmd.Dir = ini.WorkingDir
801 if out, err := cmd.CombinedOutput(); err != nil {
802 return fmt.Errorf("git fetch: %s: %w", out, err)
803 }
804 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
805 cmd.Dir = ini.WorkingDir
806 if out, err := cmd.CombinedOutput(); err != nil {
807 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
808 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700809 a.lastHEAD = ini.Commit
810 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000811 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700812 a.initialCommit = ini.Commit
813 if ini.HostAddr != "" {
814 a.url = "http://" + ini.HostAddr
815 }
816 }
817 a.workingDir = ini.WorkingDir
818
819 if !ini.NoGit {
820 repoRoot, err := repoRoot(ctx, a.workingDir)
821 if err != nil {
822 return fmt.Errorf("repoRoot: %w", err)
823 }
824 a.repoRoot = repoRoot
825
826 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
827 if err != nil {
828 return fmt.Errorf("resolveRef: %w", err)
829 }
830 a.initialCommit = commitHash
831
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000832 if experiment.Enabled("memory") {
833 slog.Info("running codebase analysis")
834 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
835 if err != nil {
836 slog.Warn("failed to analyze codebase", "error", err)
837 }
838 a.codebase = codebase
839 }
840
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000841 llmCodeReview := codereview.NoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700842 if experiment.Enabled("llm_review") {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000843 llmCodeReview = codereview.DoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700844 }
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000845 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit, llmCodeReview)
Earl Lee2e463fb2025-04-17 11:22:22 -0700846 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000847 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700848 }
849 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000850
851 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700852 }
853 a.lastHEAD = a.initialCommit
854 a.convo = a.initConvo()
855 close(a.ready)
856 return nil
857}
858
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700859//go:embed agent_system_prompt.txt
860var agentSystemPrompt string
861
Earl Lee2e463fb2025-04-17 11:22:22 -0700862// initConvo initializes the conversation.
863// It must not be called until all agent fields are initialized,
864// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700865func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700866 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700867 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700868 convo.PromptCaching = true
869 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000870 convo.SystemPrompt = a.renderSystemPrompt()
Earl Lee2e463fb2025-04-17 11:22:22 -0700871
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000872 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
873 bashPermissionCheck := func(command string) error {
874 // Check if branch name is set
875 a.mu.Lock()
876 branchSet := a.branchName != ""
877 a.mu.Unlock()
878
879 // If branch is set, all commands are allowed
880 if branchSet {
881 return nil
882 }
883
884 // If branch is not set, check if this is a git commit command
885 willCommit, err := bashkit.WillRunGitCommit(command)
886 if err != nil {
887 // If there's an error checking, we should allow the command to proceed
888 return nil
889 }
890
891 // If it's a git commit and branch is not set, return an error
892 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000893 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000894 }
895
896 return nil
897 }
898
899 // Create a custom bash tool with the permission check
900 bashTool := claudetool.NewBashTool(bashPermissionCheck)
901
Earl Lee2e463fb2025-04-17 11:22:22 -0700902 // Register all tools with the conversation
903 // When adding, removing, or modifying tools here, double-check that the termui tool display
904 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000905
906 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -0700907 _, supportsScreenshots := a.config.Service.(*ant.Service)
908 var bTools []*llm.Tool
909 var browserCleanup func()
910
911 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
912 // Add cleanup function to context cancel
913 go func() {
914 <-a.config.Context.Done()
915 browserCleanup()
916 }()
917 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000918
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700919 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000920 bashTool, claudetool.Keyword,
Josh Bleecher Snyder93202652025-05-08 02:05:57 +0000921 claudetool.Think, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000922 a.codereview.Tool(),
923 }
924
925 // One-shot mode is non-interactive, multiple choice requires human response
926 if !a.config.OneShot {
927 convo.Tools = append(convo.Tools, a.multipleChoiceTool())
Earl Lee2e463fb2025-04-17 11:22:22 -0700928 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000929
930 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -0700931 if a.config.UseAnthropicEdit {
932 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
933 } else {
934 convo.Tools = append(convo.Tools, claudetool.Patch)
935 }
936 convo.Listener = a
937 return convo
938}
939
Sean McCullough485afc62025-04-28 14:28:39 -0700940func (a *Agent) multipleChoiceTool() *llm.Tool {
941 ret := &llm.Tool{
942 Name: "multiplechoice",
943 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.",
Sean McCullough021557a2025-05-05 23:20:53 +0000944 EndsTurn: true,
Sean McCullough485afc62025-04-28 14:28:39 -0700945 InputSchema: json.RawMessage(`{
946 "type": "object",
947 "description": "The question and a list of answers you would expect the user to choose from.",
948 "properties": {
949 "question": {
950 "type": "string",
951 "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?'"
952 },
953 "responseOptions": {
954 "type": "array",
955 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
956 "items": {
957 "type": "object",
958 "properties": {
959 "caption": {
960 "type": "string",
961 "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'"
962 },
963 "responseText": {
964 "type": "string",
965 "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'"
966 }
967 },
968 "required": ["caption", "responseText"]
969 }
970 }
971 },
972 "required": ["question", "responseOptions"]
973}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700974 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Sean McCullough485afc62025-04-28 14:28:39 -0700975 // The Run logic for "multiplchoice" tool is a no-op on the server.
976 // The UI will present a list of options for the user to select from,
977 // and that's it as far as "executing" the tool_use goes.
978 // When the user *does* select one of the presented options, that
979 // responseText gets sent as a chat message on behalf of the user.
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700980 return llm.TextContent("end your turn and wait for the user to respond"), nil
Sean McCullough485afc62025-04-28 14:28:39 -0700981 },
982 }
983 return ret
984}
985
986type MultipleChoiceOption struct {
987 Caption string `json:"caption"`
988 ResponseText string `json:"responseText"`
989}
990
991type MultipleChoiceParams struct {
992 Question string `json:"question"`
993 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
994}
995
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000996// branchExists reports whether branchName exists, either locally or in well-known remotes.
997func branchExists(dir, branchName string) bool {
998 refs := []string{
999 "refs/heads/",
1000 "refs/remotes/origin/",
1001 "refs/remotes/sketch-host/",
1002 }
1003 for _, ref := range refs {
1004 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1005 cmd.Dir = dir
1006 if cmd.Run() == nil { // exit code 0 means branch exists
1007 return true
1008 }
1009 }
1010 return false
1011}
1012
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001013func (a *Agent) titleTool() *llm.Tool {
1014 description := `Sets the conversation title.`
1015 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001016 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001017 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001018 InputSchema: json.RawMessage(`{
1019 "type": "object",
1020 "properties": {
1021 "title": {
1022 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001023 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001024 }
1025 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001026 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001027}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001028 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001029 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001030 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001031 }
1032 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001033 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001034 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001035
1036 // We don't allow changing the title once set to be consistent with the previous behavior
1037 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001038 t := a.Title()
1039 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001040 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001041 }
1042
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001043 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001044 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001045 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001046
1047 a.SetTitle(params.Title)
1048 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001049 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001050 },
1051 }
1052 return titleTool
1053}
1054
1055func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001056 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 +00001057 preCommit := &llm.Tool{
1058 Name: "precommit",
1059 Description: description,
1060 InputSchema: json.RawMessage(`{
1061 "type": "object",
1062 "properties": {
1063 "branch_name": {
1064 "type": "string",
1065 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1066 }
1067 },
1068 "required": ["branch_name"]
1069}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001070 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001071 var params struct {
1072 BranchName string `json:"branch_name"`
1073 }
1074 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001075 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001076 }
1077
1078 b := a.BranchName()
1079 if b != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001080 return nil, fmt.Errorf("branch already set to: %s", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001081 }
1082
1083 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001084 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001085 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001086 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001087 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001088 }
1089 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001090 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001091 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001092 }
1093
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001094 a.SetBranch(branchName)
1095 response := fmt.Sprintf("Branch name set to %q", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001096
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001097 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1098 if err != nil {
1099 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1100 }
1101 if len(styleHint) > 0 {
1102 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001103 }
1104
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001105 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001106 },
1107 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001108 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001109}
1110
1111func (a *Agent) Ready() <-chan struct{} {
1112 return a.ready
1113}
1114
1115func (a *Agent) UserMessage(ctx context.Context, msg string) {
1116 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1117 a.inbox <- msg
1118}
1119
Earl Lee2e463fb2025-04-17 11:22:22 -07001120func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1121 return a.convo.CancelToolUse(toolUseID, cause)
1122}
1123
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001124func (a *Agent) CancelTurn(cause error) {
1125 a.cancelTurnMu.Lock()
1126 defer a.cancelTurnMu.Unlock()
1127 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001128 // Force state transition to cancelled state
1129 ctx := a.config.Context
1130 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001131 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001132 }
1133}
1134
1135func (a *Agent) Loop(ctxOuter context.Context) {
1136 for {
1137 select {
1138 case <-ctxOuter.Done():
1139 return
1140 default:
1141 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001142 a.cancelTurnMu.Lock()
1143 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001144 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001145 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001146 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001147 a.cancelTurn = cancel
1148 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001149 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1150 if err != nil {
1151 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1152 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001153 cancel(nil)
1154 }
1155 }
1156}
1157
1158func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1159 if m.Timestamp.IsZero() {
1160 m.Timestamp = time.Now()
1161 }
1162
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001163 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1164 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1165 m.Content = m.ToolResult
1166 }
1167
Earl Lee2e463fb2025-04-17 11:22:22 -07001168 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1169 if m.EndOfTurn && m.Type == AgentMessageType {
1170 turnDuration := time.Since(a.startOfTurn)
1171 m.TurnDuration = &turnDuration
1172 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1173 }
1174
Earl Lee2e463fb2025-04-17 11:22:22 -07001175 a.mu.Lock()
1176 defer a.mu.Unlock()
1177 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001178 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001179 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001180
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001181 // Notify all subscribers
1182 for _, ch := range a.subscribers {
1183 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001184 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001185}
1186
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001187func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1188 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001189 if block {
1190 select {
1191 case <-ctx.Done():
1192 return m, ctx.Err()
1193 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001194 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001195 }
1196 }
1197 for {
1198 select {
1199 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001200 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001201 default:
1202 return m, nil
1203 }
1204 }
1205}
1206
Sean McCullough885a16a2025-04-30 02:49:25 +00001207// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001208func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001209 // Reset the start of turn time
1210 a.startOfTurn = time.Now()
1211
Sean McCullough96b60dd2025-04-30 09:49:10 -07001212 // Transition to waiting for user input state
1213 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1214
Sean McCullough885a16a2025-04-30 02:49:25 +00001215 // Process initial user message
1216 initialResp, err := a.processUserMessage(ctx)
1217 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001218 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001219 return err
1220 }
1221
1222 // Handle edge case where both initialResp and err are nil
1223 if initialResp == nil {
1224 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001225 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1226
Sean McCullough9f4b8082025-04-30 17:34:07 +00001227 a.pushToOutbox(ctx, errorMessage(err))
1228 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001229 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001230
Earl Lee2e463fb2025-04-17 11:22:22 -07001231 // We do this as we go, but let's also do it at the end of the turn
1232 defer func() {
1233 if _, err := a.handleGitCommits(ctx); err != nil {
1234 // Just log the error, don't stop execution
1235 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1236 }
1237 }()
1238
Sean McCullougha1e0e492025-05-01 10:51:08 -07001239 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001240 resp := initialResp
1241 for {
1242 // Check if we are over budget
1243 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001244 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001245 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001246 }
1247
1248 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001249 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001250 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001251 break
1252 }
1253
Sean McCullough96b60dd2025-04-30 09:49:10 -07001254 // Transition to tool use requested state
1255 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1256
Sean McCullough885a16a2025-04-30 02:49:25 +00001257 // Handle tool execution
1258 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1259 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001260 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001261 }
1262
Sean McCullougha1e0e492025-05-01 10:51:08 -07001263 if toolResp == nil {
1264 return fmt.Errorf("cannot continue conversation with a nil tool response")
1265 }
1266
Sean McCullough885a16a2025-04-30 02:49:25 +00001267 // Set the response for the next iteration
1268 resp = toolResp
1269 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001270
1271 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001272}
1273
1274// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001275func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001276 // Wait for at least one message from the user
1277 msgs, err := a.GatherMessages(ctx, true)
1278 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001279 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001280 return nil, err
1281 }
1282
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001283 userMessage := llm.Message{
1284 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001285 Content: msgs,
1286 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001287
Sean McCullough96b60dd2025-04-30 09:49:10 -07001288 // Transition to sending to LLM state
1289 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1290
Sean McCullough885a16a2025-04-30 02:49:25 +00001291 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001292 resp, err := a.convo.SendMessage(userMessage)
1293 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001294 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001295 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001296 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001297 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001298
Sean McCullough96b60dd2025-04-30 09:49:10 -07001299 // Transition to processing LLM response state
1300 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1301
Sean McCullough885a16a2025-04-30 02:49:25 +00001302 return resp, nil
1303}
1304
1305// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001306func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1307 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001308 cancelled := false
1309
Sean McCullough96b60dd2025-04-30 09:49:10 -07001310 // Transition to checking for cancellation state
1311 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1312
Sean McCullough885a16a2025-04-30 02:49:25 +00001313 // Check if the operation was cancelled by the user
1314 select {
1315 case <-ctx.Done():
1316 // Don't actually run any of the tools, but rather build a response
1317 // for each tool_use message letting the LLM know that user canceled it.
1318 var err error
1319 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001320 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001321 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001322 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001323 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001324 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001325 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001326 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001327 // Transition to running tool state
1328 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1329
Sean McCullough885a16a2025-04-30 02:49:25 +00001330 // Add working directory to context for tool execution
1331 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1332
1333 // Execute the tools
1334 var err error
1335 results, err = a.convo.ToolResultContents(ctx, resp)
1336 if ctx.Err() != nil { // e.g. the user canceled the operation
1337 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001338 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001339 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001340 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001341 a.pushToOutbox(ctx, errorMessage(err))
1342 }
1343 }
1344
1345 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001346 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001347 autoqualityMessages := a.processGitChanges(ctx)
1348
1349 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001350 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001351 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001352 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001353 return false, nil
1354 }
1355
1356 // Continue the conversation with tool results and any user messages
1357 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1358}
1359
1360// processGitChanges checks for new git commits and runs autoformatters if needed
1361func (a *Agent) processGitChanges(ctx context.Context) []string {
1362 // Check for git commits after tool execution
1363 newCommits, err := a.handleGitCommits(ctx)
1364 if err != nil {
1365 // Just log the error, don't stop execution
1366 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1367 return nil
1368 }
1369
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001370 // Run mechanical checks if there was exactly one new commit.
1371 if len(newCommits) != 1 {
1372 return nil
1373 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001374 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001375 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1376 msg := a.codereview.RunMechanicalChecks(ctx)
1377 if msg != "" {
1378 a.pushToOutbox(ctx, AgentMessage{
1379 Type: AutoMessageType,
1380 Content: msg,
1381 Timestamp: time.Now(),
1382 })
1383 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001384 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001385
1386 return autoqualityMessages
1387}
1388
1389// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001390func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001391 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001392 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001393 msgs, err := a.GatherMessages(ctx, false)
1394 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001395 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001396 return false, nil
1397 }
1398
1399 // Inject any auto-generated messages from quality checks
1400 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001401 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001402 }
1403
1404 // Handle cancellation by appending a message about it
1405 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001406 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001407 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001408 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001409 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1410 } else if err := a.convo.OverBudget(); err != nil {
1411 // Handle budget issues by appending a message about it
1412 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 -07001413 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001414 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1415 }
1416
1417 // Combine tool results with user messages
1418 results = append(results, msgs...)
1419
1420 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001421 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001422 resp, err := a.convo.SendMessage(llm.Message{
1423 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001424 Content: results,
1425 })
1426 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001427 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001428 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1429 return true, nil // Return true to continue the conversation, but with no response
1430 }
1431
Sean McCullough96b60dd2025-04-30 09:49:10 -07001432 // Transition back to processing LLM response
1433 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1434
Sean McCullough885a16a2025-04-30 02:49:25 +00001435 if cancelled {
1436 return false, nil
1437 }
1438
1439 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001440}
1441
1442func (a *Agent) overBudget(ctx context.Context) error {
1443 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001444 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001445 m := budgetMessage(err)
1446 m.Content = m.Content + "\n\nBudget reset."
1447 a.pushToOutbox(ctx, budgetMessage(err))
1448 a.convo.ResetBudget(a.originalBudget)
1449 return err
1450 }
1451 return nil
1452}
1453
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001454func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001455 // Collect all text content
1456 var allText strings.Builder
1457 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001458 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001459 if allText.Len() > 0 {
1460 allText.WriteString("\n\n")
1461 }
1462 allText.WriteString(content.Text)
1463 }
1464 }
1465 return allText.String()
1466}
1467
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001468func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001469 a.mu.Lock()
1470 defer a.mu.Unlock()
1471 return a.convo.CumulativeUsage()
1472}
1473
Earl Lee2e463fb2025-04-17 11:22:22 -07001474// Diff returns a unified diff of changes made since the agent was instantiated.
1475func (a *Agent) Diff(commit *string) (string, error) {
1476 if a.initialCommit == "" {
1477 return "", fmt.Errorf("no initial commit reference available")
1478 }
1479
1480 // Find the repository root
1481 ctx := context.Background()
1482
1483 // If a specific commit hash is provided, show just that commit's changes
1484 if commit != nil && *commit != "" {
1485 // Validate that the commit looks like a valid git SHA
1486 if !isValidGitSHA(*commit) {
1487 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1488 }
1489
1490 // Get the diff for just this commit
1491 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1492 cmd.Dir = a.repoRoot
1493 output, err := cmd.CombinedOutput()
1494 if err != nil {
1495 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1496 }
1497 return string(output), nil
1498 }
1499
1500 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1501 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1502 cmd.Dir = a.repoRoot
1503 output, err := cmd.CombinedOutput()
1504 if err != nil {
1505 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1506 }
1507
1508 return string(output), nil
1509}
1510
1511// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1512func (a *Agent) InitialCommit() string {
1513 return a.initialCommit
1514}
1515
1516// handleGitCommits() highlights new commits to the user. When running
1517// under docker, new HEADs are pushed to a branch according to the title.
1518func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1519 if a.repoRoot == "" {
1520 return nil, nil
1521 }
1522
1523 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1524 if err != nil {
1525 return nil, err
1526 }
1527 if head == a.lastHEAD {
1528 return nil, nil // nothing to do
1529 }
1530 defer func() {
1531 a.lastHEAD = head
1532 }()
1533
1534 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1535 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1536 // to the last 100 commits.
1537 var commits []*GitCommit
1538
1539 // Get commits since the initial commit
1540 // Format: <hash>\0<subject>\0<body>\0
1541 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1542 // Limit to 100 commits to avoid overwhelming the user
1543 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1544 cmd.Dir = a.repoRoot
1545 output, err := cmd.Output()
1546 if err != nil {
1547 return nil, fmt.Errorf("failed to get git log: %w", err)
1548 }
1549
1550 // Parse git log output and filter out already seen commits
1551 parsedCommits := parseGitLog(string(output))
1552
1553 var headCommit *GitCommit
1554
1555 // Filter out commits we've already seen
1556 for _, commit := range parsedCommits {
1557 if commit.Hash == head {
1558 headCommit = &commit
1559 }
1560
1561 // Skip if we've seen this commit before. If our head has changed, always include that.
1562 if a.seenCommits[commit.Hash] && commit.Hash != head {
1563 continue
1564 }
1565
1566 // Mark this commit as seen
1567 a.seenCommits[commit.Hash] = true
1568
1569 // Add to our list of new commits
1570 commits = append(commits, &commit)
1571 }
1572
1573 if a.gitRemoteAddr != "" {
1574 if headCommit == nil {
1575 // I think this can only happen if we have a bug or if there's a race.
1576 headCommit = &GitCommit{}
1577 headCommit.Hash = head
1578 headCommit.Subject = "unknown"
1579 commits = append(commits, headCommit)
1580 }
1581
Philip Zeyliger113e2052025-05-09 21:59:40 +00001582 originalBranch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
1583 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001584
1585 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1586 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1587 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001588
1589 // Try up to 10 times with different branch names if the branch is checked out on the remote
1590 var out []byte
1591 var err error
1592 for retries := range 10 {
1593 if retries > 0 {
1594 // Add a numeric suffix to the branch name
1595 branch = fmt.Sprintf("%s%d", originalBranch, retries)
1596 }
1597
1598 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1599 cmd.Dir = a.workingDir
1600 out, err = cmd.CombinedOutput()
1601
1602 if err == nil {
1603 // Success! Break out of the retry loop
1604 break
1605 }
1606
1607 // Check if this is the "refusing to update checked out branch" error
1608 if !strings.Contains(string(out), "refusing to update checked out branch") {
1609 // This is a different error, so don't retry
1610 break
1611 }
1612
1613 // If we're on the last retry, we'll report the error
1614 if retries == 9 {
1615 break
1616 }
1617 }
1618
1619 if err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001620 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1621 } else {
1622 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001623 // Update the agent's branch name if we ended up using a different one
1624 if branch != originalBranch {
1625 a.branchName = branch
1626 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001627 }
1628 }
1629
1630 // If we found new commits, create a message
1631 if len(commits) > 0 {
1632 msg := AgentMessage{
1633 Type: CommitMessageType,
1634 Timestamp: time.Now(),
1635 Commits: commits,
1636 }
1637 a.pushToOutbox(ctx, msg)
1638 }
1639 return commits, nil
1640}
1641
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001642func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001643 return strings.Map(func(r rune) rune {
1644 // lowercase
1645 if r >= 'A' && r <= 'Z' {
1646 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001647 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001648 // replace spaces with dashes
1649 if r == ' ' {
1650 return '-'
1651 }
1652 // allow alphanumerics and dashes
1653 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1654 return r
1655 }
1656 return -1
1657 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001658}
1659
1660// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1661// and returns an array of GitCommit structs.
1662func parseGitLog(output string) []GitCommit {
1663 var commits []GitCommit
1664
1665 // No output means no commits
1666 if len(output) == 0 {
1667 return commits
1668 }
1669
1670 // Split by NULL byte
1671 parts := strings.Split(output, "\x00")
1672
1673 // Process in triplets (hash, subject, body)
1674 for i := 0; i < len(parts); i++ {
1675 // Skip empty parts
1676 if parts[i] == "" {
1677 continue
1678 }
1679
1680 // This should be a hash
1681 hash := strings.TrimSpace(parts[i])
1682
1683 // Make sure we have at least a subject part available
1684 if i+1 >= len(parts) {
1685 break // No more parts available
1686 }
1687
1688 // Get the subject
1689 subject := strings.TrimSpace(parts[i+1])
1690
1691 // Get the body if available
1692 body := ""
1693 if i+2 < len(parts) {
1694 body = strings.TrimSpace(parts[i+2])
1695 }
1696
1697 // Skip to the next triplet
1698 i += 2
1699
1700 commits = append(commits, GitCommit{
1701 Hash: hash,
1702 Subject: subject,
1703 Body: body,
1704 })
1705 }
1706
1707 return commits
1708}
1709
1710func repoRoot(ctx context.Context, dir string) (string, error) {
1711 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1712 stderr := new(strings.Builder)
1713 cmd.Stderr = stderr
1714 cmd.Dir = dir
1715 out, err := cmd.Output()
1716 if err != nil {
1717 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1718 }
1719 return strings.TrimSpace(string(out)), nil
1720}
1721
1722func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1723 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1724 stderr := new(strings.Builder)
1725 cmd.Stderr = stderr
1726 cmd.Dir = dir
1727 out, err := cmd.Output()
1728 if err != nil {
1729 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1730 }
1731 // TODO: validate that out is valid hex
1732 return strings.TrimSpace(string(out)), nil
1733}
1734
1735// isValidGitSHA validates if a string looks like a valid git SHA hash.
1736// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1737func isValidGitSHA(sha string) bool {
1738 // Git SHA must be a hexadecimal string with at least 4 characters
1739 if len(sha) < 4 || len(sha) > 40 {
1740 return false
1741 }
1742
1743 // Check if the string only contains hexadecimal characters
1744 for _, char := range sha {
1745 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1746 return false
1747 }
1748 }
1749
1750 return true
1751}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001752
1753// getGitOrigin returns the URL of the git remote 'origin' if it exists
1754func getGitOrigin(ctx context.Context, dir string) string {
1755 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1756 cmd.Dir = dir
1757 stderr := new(strings.Builder)
1758 cmd.Stderr = stderr
1759 out, err := cmd.Output()
1760 if err != nil {
1761 return ""
1762 }
1763 return strings.TrimSpace(string(out))
1764}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001765
1766func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1767 cmd := exec.CommandContext(ctx, "git", "stash")
1768 cmd.Dir = workingDir
1769 if out, err := cmd.CombinedOutput(); err != nil {
1770 return fmt.Errorf("git stash: %s: %v", out, err)
1771 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001772 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001773 cmd.Dir = workingDir
1774 if out, err := cmd.CombinedOutput(); err != nil {
1775 return fmt.Errorf("git fetch: %s: %w", out, err)
1776 }
1777 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1778 cmd.Dir = workingDir
1779 if out, err := cmd.CombinedOutput(); err != nil {
1780 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1781 }
1782 a.lastHEAD = revision
1783 a.initialCommit = revision
1784 return nil
1785}
1786
1787func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1788 a.mu.Lock()
1789 a.title = ""
1790 a.firstMessageIndex = len(a.history)
1791 a.convo = a.initConvo()
1792 gitReset := func() error {
1793 if a.config.InDocker && rev != "" {
1794 err := a.initGitRevision(ctx, a.workingDir, rev)
1795 if err != nil {
1796 return err
1797 }
1798 } else if !a.config.InDocker && rev != "" {
1799 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1800 }
1801 return nil
1802 }
1803 err := gitReset()
1804 a.mu.Unlock()
1805 if err != nil {
1806 a.pushToOutbox(a.config.Context, errorMessage(err))
1807 }
1808
1809 a.pushToOutbox(a.config.Context, AgentMessage{
1810 Type: AgentMessageType, Content: "Conversation restarted.",
1811 })
1812 if initialPrompt != "" {
1813 a.UserMessage(ctx, initialPrompt)
1814 }
1815 return nil
1816}
1817
1818func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1819 msg := `The user has requested a suggestion for a re-prompt.
1820
1821 Given the current conversation thus far, suggest a re-prompt that would
1822 capture the instructions and feedback so far, as well as any
1823 research or other information that would be helpful in implementing
1824 the task.
1825
1826 Reply with ONLY the reprompt text.
1827 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001828 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001829 // By doing this in a subconversation, the agent doesn't call tools (because
1830 // there aren't any), and there's not a concurrency risk with on-going other
1831 // outstanding conversations.
1832 convo := a.convo.SubConvoWithHistory()
1833 resp, err := convo.SendMessage(userMessage)
1834 if err != nil {
1835 a.pushToOutbox(ctx, errorMessage(err))
1836 return "", err
1837 }
1838 textContent := collectTextContent(resp)
1839 return textContent, nil
1840}
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001841
1842// systemPromptData contains the data used to render the system prompt template
1843type systemPromptData struct {
1844 EditPrompt string
1845 ClientGOOS string
1846 ClientGOARCH string
1847 WorkingDir string
1848 RepoRoot string
1849 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001850 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001851}
1852
1853// renderSystemPrompt renders the system prompt template.
1854func (a *Agent) renderSystemPrompt() string {
1855 // Determine the appropriate edit prompt based on config
1856 var editPrompt string
1857 if a.config.UseAnthropicEdit {
1858 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."
1859 } else {
1860 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1861 }
1862
1863 data := systemPromptData{
1864 EditPrompt: editPrompt,
1865 ClientGOOS: a.config.ClientGOOS,
1866 ClientGOARCH: a.config.ClientGOARCH,
1867 WorkingDir: a.workingDir,
1868 RepoRoot: a.repoRoot,
1869 InitialCommit: a.initialCommit,
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001870 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001871 }
1872
1873 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1874 if err != nil {
1875 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1876 }
1877 buf := new(strings.Builder)
1878 err = tmpl.Execute(buf, data)
1879 if err != nil {
1880 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1881 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001882 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001883 return buf.String()
1884}