blob: b3fd6c489045bb6180711a89b32a4ba33642b92f [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 Snydere2518e52025-04-29 11:13:40 -070026 "sketch.dev/experiment"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070027 "sketch.dev/llm"
28 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070029)
30
31const (
32 userCancelMessage = "user requested agent to stop handling responses"
33)
34
Philip Zeyligerb7c58752025-05-01 10:10:17 -070035type MessageIterator interface {
36 // Next blocks until the next message is available. It may
37 // return nil if the underlying iterator context is done.
38 Next() *AgentMessage
39 Close()
40}
41
Earl Lee2e463fb2025-04-17 11:22:22 -070042type CodingAgent interface {
43 // Init initializes an agent inside a docker container.
44 Init(AgentInit) error
45
46 // Ready returns a channel closed after Init successfully called.
47 Ready() <-chan struct{}
48
49 // URL reports the HTTP URL of this agent.
50 URL() string
51
52 // UserMessage enqueues a message to the agent and returns immediately.
53 UserMessage(ctx context.Context, msg string)
54
Philip Zeyligerb7c58752025-05-01 10:10:17 -070055 // Returns an iterator that finishes when the context is done and
56 // starts with the given message index.
57 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070058
59 // Loop begins the agent loop returns only when ctx is cancelled.
60 Loop(ctx context.Context)
61
Sean McCulloughedc88dc2025-04-30 02:55:01 +000062 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070063
64 CancelToolUse(toolUseID string, cause error) error
65
66 // Returns a subset of the agent's message history.
67 Messages(start int, end int) []AgentMessage
68
69 // Returns the current number of messages in the history
70 MessageCount() int
71
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070072 TotalUsage() conversation.CumulativeUsage
73 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070074
Earl Lee2e463fb2025-04-17 11:22:22 -070075 WorkingDir() string
76
77 // Diff returns a unified diff of changes made since the agent was instantiated.
78 // If commit is non-nil, it shows the diff for just that specific commit.
79 Diff(commit *string) (string, error)
80
81 // InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
82 InitialCommit() string
83
84 // Title returns the current title of the conversation.
85 Title() string
86
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000087 // BranchName returns the git branch name for the conversation.
88 BranchName() string
89
Earl Lee2e463fb2025-04-17 11:22:22 -070090 // OS returns the operating system of the client.
91 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +000092
Philip Zeyligerc72fff52025-04-29 20:17:54 +000093 // SessionID returns the unique session identifier.
94 SessionID() string
95
Philip Zeyliger99a9a022025-04-27 15:15:25 +000096 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
97 OutstandingLLMCallCount() int
98
99 // OutstandingToolCalls returns the names of outstanding tool calls.
100 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000101 OutsideOS() string
102 OutsideHostname() string
103 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000104 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000105 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
106 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700107
108 // RestartConversation resets the conversation history
109 RestartConversation(ctx context.Context, rev string, initialPrompt string) error
110 // SuggestReprompt suggests a re-prompt based on the current conversation.
111 SuggestReprompt(ctx context.Context) (string, error)
112 // IsInContainer returns true if the agent is running in a container
113 IsInContainer() bool
114 // FirstMessageIndex returns the index of the first message in the current conversation
115 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700116
117 CurrentStateName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700118}
119
120type CodingAgentMessageType string
121
122const (
123 UserMessageType CodingAgentMessageType = "user"
124 AgentMessageType CodingAgentMessageType = "agent"
125 ErrorMessageType CodingAgentMessageType = "error"
126 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
127 ToolUseMessageType CodingAgentMessageType = "tool"
128 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
129 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
130
131 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
132)
133
134type AgentMessage struct {
135 Type CodingAgentMessageType `json:"type"`
136 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
137 EndOfTurn bool `json:"end_of_turn"`
138
139 Content string `json:"content"`
140 ToolName string `json:"tool_name,omitempty"`
141 ToolInput string `json:"input,omitempty"`
142 ToolResult string `json:"tool_result,omitempty"`
143 ToolError bool `json:"tool_error,omitempty"`
144 ToolCallId string `json:"tool_call_id,omitempty"`
145
146 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
147 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
148
Sean McCulloughd9f13372025-04-21 15:08:49 -0700149 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
150 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
151
Earl Lee2e463fb2025-04-17 11:22:22 -0700152 // Commits is a list of git commits for a commit message
153 Commits []*GitCommit `json:"commits,omitempty"`
154
155 Timestamp time.Time `json:"timestamp"`
156 ConversationID string `json:"conversation_id"`
157 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700158 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700159
160 // Message timing information
161 StartTime *time.Time `json:"start_time,omitempty"`
162 EndTime *time.Time `json:"end_time,omitempty"`
163 Elapsed *time.Duration `json:"elapsed,omitempty"`
164
165 // Turn duration - the time taken for a complete agent turn
166 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
167
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000168 // HideOutput indicates that this message should not be rendered in the UI.
169 // This is useful for subconversations that generate output that shouldn't be shown to the user.
170 HideOutput bool `json:"hide_output,omitempty"`
171
Earl Lee2e463fb2025-04-17 11:22:22 -0700172 Idx int `json:"idx"`
173}
174
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000175// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700176func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700177 if convo == nil {
178 m.ConversationID = ""
179 m.ParentConversationID = nil
180 return
181 }
182 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000183 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700184 if convo.Parent != nil {
185 m.ParentConversationID = &convo.Parent.ID
186 }
187}
188
Earl Lee2e463fb2025-04-17 11:22:22 -0700189// GitCommit represents a single git commit for a commit message
190type GitCommit struct {
191 Hash string `json:"hash"` // Full commit hash
192 Subject string `json:"subject"` // Commit subject line
193 Body string `json:"body"` // Full commit message body
194 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
195}
196
197// ToolCall represents a single tool call within an agent message
198type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700199 Name string `json:"name"`
200 Input string `json:"input"`
201 ToolCallId string `json:"tool_call_id"`
202 ResultMessage *AgentMessage `json:"result_message,omitempty"`
203 Args string `json:"args,omitempty"`
204 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700205}
206
207func (a *AgentMessage) Attr() slog.Attr {
208 var attrs []any = []any{
209 slog.String("type", string(a.Type)),
210 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700211 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700212 if a.EndOfTurn {
213 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
214 }
215 if a.Content != "" {
216 attrs = append(attrs, slog.String("content", a.Content))
217 }
218 if a.ToolName != "" {
219 attrs = append(attrs, slog.String("tool_name", a.ToolName))
220 }
221 if a.ToolInput != "" {
222 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
223 }
224 if a.Elapsed != nil {
225 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
226 }
227 if a.TurnDuration != nil {
228 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
229 }
230 if a.ToolResult != "" {
231 attrs = append(attrs, slog.String("tool_result", a.ToolResult))
232 }
233 if a.ToolError {
234 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
235 }
236 if len(a.ToolCalls) > 0 {
237 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
238 for i, tc := range a.ToolCalls {
239 toolCallAttrs = append(toolCallAttrs, slog.Group(
240 fmt.Sprintf("tool_call_%d", i),
241 slog.String("name", tc.Name),
242 slog.String("input", tc.Input),
243 ))
244 }
245 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
246 }
247 if a.ConversationID != "" {
248 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
249 }
250 if a.ParentConversationID != nil {
251 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
252 }
253 if a.Usage != nil && !a.Usage.IsZero() {
254 attrs = append(attrs, a.Usage.Attr())
255 }
256 // TODO: timestamp, convo ids, idx?
257 return slog.Group("agent_message", attrs...)
258}
259
260func errorMessage(err error) AgentMessage {
261 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
262 if os.Getenv(("DEBUG")) == "1" {
263 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
264 }
265
266 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
267}
268
269func budgetMessage(err error) AgentMessage {
270 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
271}
272
273// ConvoInterface defines the interface for conversation interactions
274type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700275 CumulativeUsage() conversation.CumulativeUsage
276 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700277 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700278 SendMessage(message llm.Message) (*llm.Response, error)
279 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700280 GetID() string
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700281 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error)
282 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700283 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700284 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700285}
286
287type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700288 convo ConvoInterface
289 config AgentConfig // config for this agent
290 workingDir string
291 repoRoot string // workingDir may be a subdir of repoRoot
292 url string
293 firstMessageIndex int // index of the first message in the current conversation
294 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
295 initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
296 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000297 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700298 ready chan struct{} // closed when the agent is initialized (only when under docker)
299 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700300 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700301 title string
302 branchName string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000303 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700304 // State machine to track agent state
305 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000306 // Outside information
307 outsideHostname string
308 outsideOS string
309 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000310 // URL of the git remote 'origin' if it exists
311 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700312
313 // Time when the current turn started (reset at the beginning of InnerLoop)
314 startOfTurn time.Time
315
316 // Inbox - for messages from the user to the agent.
317 // sent on by UserMessage
318 // . e.g. when user types into the chat textarea
319 // read from by GatherMessages
320 inbox chan string
321
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000322 // protects cancelTurn
323 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700324 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000325 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700326
327 // protects following
328 mu sync.Mutex
329
330 // Stores all messages for this agent
331 history []AgentMessage
332
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700333 // Iterators add themselves here when they're ready to be notified of new messages.
334 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700335
336 // Track git commits we've already seen (by hash)
337 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000338
339 // Track outstanding LLM call IDs
340 outstandingLLMCalls map[string]struct{}
341
342 // Track outstanding tool calls by ID with their names
343 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700344}
345
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700346// NewIterator implements CodingAgent.
347func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
348 a.mu.Lock()
349 defer a.mu.Unlock()
350
351 return &MessageIteratorImpl{
352 agent: a,
353 ctx: ctx,
354 nextMessageIdx: nextMessageIdx,
355 ch: make(chan *AgentMessage, 100),
356 }
357}
358
359type MessageIteratorImpl struct {
360 agent *Agent
361 ctx context.Context
362 nextMessageIdx int
363 ch chan *AgentMessage
364 subscribed bool
365}
366
367func (m *MessageIteratorImpl) Close() {
368 m.agent.mu.Lock()
369 defer m.agent.mu.Unlock()
370 // Delete ourselves from the subscribers list
371 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
372 return x == m.ch
373 })
374 close(m.ch)
375}
376
377func (m *MessageIteratorImpl) Next() *AgentMessage {
378 // We avoid subscription at creation to let ourselves catch up to "current state"
379 // before subscribing.
380 if !m.subscribed {
381 m.agent.mu.Lock()
382 if m.nextMessageIdx < len(m.agent.history) {
383 msg := &m.agent.history[m.nextMessageIdx]
384 m.nextMessageIdx++
385 m.agent.mu.Unlock()
386 return msg
387 }
388 // The next message doesn't exist yet, so let's subscribe
389 m.agent.subscribers = append(m.agent.subscribers, m.ch)
390 m.subscribed = true
391 m.agent.mu.Unlock()
392 }
393
394 for {
395 select {
396 case <-m.ctx.Done():
397 m.agent.mu.Lock()
398 // Delete ourselves from the subscribers list
399 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
400 return x == m.ch
401 })
402 m.subscribed = false
403 m.agent.mu.Unlock()
404 return nil
405 case msg, ok := <-m.ch:
406 if !ok {
407 // Close may have been called
408 return nil
409 }
410 if msg.Idx == m.nextMessageIdx {
411 m.nextMessageIdx++
412 return msg
413 }
414 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
415 panic("out of order message")
416 }
417 }
418}
419
Sean McCulloughd9d45812025-04-30 16:53:41 -0700420// Assert that Agent satisfies the CodingAgent interface.
421var _ CodingAgent = &Agent{}
422
423// StateName implements CodingAgent.
424func (a *Agent) CurrentStateName() string {
425 if a.stateMachine == nil {
426 return ""
427 }
428 return a.stateMachine.currentState.String()
429}
430
Earl Lee2e463fb2025-04-17 11:22:22 -0700431func (a *Agent) URL() string { return a.url }
432
433// Title returns the current title of the conversation.
434// If no title has been set, returns an empty string.
435func (a *Agent) Title() string {
436 a.mu.Lock()
437 defer a.mu.Unlock()
438 return a.title
439}
440
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000441// BranchName returns the git branch name for the conversation.
442func (a *Agent) BranchName() string {
443 a.mu.Lock()
444 defer a.mu.Unlock()
445 return a.branchName
446}
447
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000448// OutstandingLLMCallCount returns the number of outstanding LLM calls.
449func (a *Agent) OutstandingLLMCallCount() int {
450 a.mu.Lock()
451 defer a.mu.Unlock()
452 return len(a.outstandingLLMCalls)
453}
454
455// OutstandingToolCalls returns the names of outstanding tool calls.
456func (a *Agent) OutstandingToolCalls() []string {
457 a.mu.Lock()
458 defer a.mu.Unlock()
459
460 tools := make([]string, 0, len(a.outstandingToolCalls))
461 for _, toolName := range a.outstandingToolCalls {
462 tools = append(tools, toolName)
463 }
464 return tools
465}
466
Earl Lee2e463fb2025-04-17 11:22:22 -0700467// OS returns the operating system of the client.
468func (a *Agent) OS() string {
469 return a.config.ClientGOOS
470}
471
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000472func (a *Agent) SessionID() string {
473 return a.config.SessionID
474}
475
Philip Zeyliger18532b22025-04-23 21:11:46 +0000476// OutsideOS returns the operating system of the outside system.
477func (a *Agent) OutsideOS() string {
478 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000479}
480
Philip Zeyliger18532b22025-04-23 21:11:46 +0000481// OutsideHostname returns the hostname of the outside system.
482func (a *Agent) OutsideHostname() string {
483 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000484}
485
Philip Zeyliger18532b22025-04-23 21:11:46 +0000486// OutsideWorkingDir returns the working directory on the outside system.
487func (a *Agent) OutsideWorkingDir() string {
488 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000489}
490
491// GitOrigin returns the URL of the git remote 'origin' if it exists.
492func (a *Agent) GitOrigin() string {
493 return a.gitOrigin
494}
495
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000496func (a *Agent) OpenBrowser(url string) {
497 if !a.IsInContainer() {
498 browser.Open(url)
499 return
500 }
501 // We're in Docker, need to send a request to the Git server
502 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700503 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000504 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700505 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000506 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700507 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000508 return
509 }
510 defer resp.Body.Close()
511 if resp.StatusCode == http.StatusOK {
512 return
513 }
514 body, _ := io.ReadAll(resp.Body)
515 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
516}
517
Sean McCullough96b60dd2025-04-30 09:49:10 -0700518// CurrentState returns the current state of the agent's state machine.
519func (a *Agent) CurrentState() State {
520 return a.stateMachine.CurrentState()
521}
522
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700523func (a *Agent) IsInContainer() bool {
524 return a.config.InDocker
525}
526
527func (a *Agent) FirstMessageIndex() int {
528 a.mu.Lock()
529 defer a.mu.Unlock()
530 return a.firstMessageIndex
531}
532
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000533// SetTitle sets the title of the conversation.
534func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700535 a.mu.Lock()
536 defer a.mu.Unlock()
537 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000538}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700539
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000540// SetBranch sets the branch name of the conversation.
541func (a *Agent) SetBranch(branchName string) {
542 a.mu.Lock()
543 defer a.mu.Unlock()
544 a.branchName = branchName
Earl Lee2e463fb2025-04-17 11:22:22 -0700545}
546
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000547// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700548func (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 +0000549 // Track the tool call
550 a.mu.Lock()
551 a.outstandingToolCalls[id] = toolName
552 a.mu.Unlock()
553}
554
Earl Lee2e463fb2025-04-17 11:22:22 -0700555// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700556func (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 +0000557 // Remove the tool call from outstanding calls
558 a.mu.Lock()
559 delete(a.outstandingToolCalls, toolID)
560 a.mu.Unlock()
561
Earl Lee2e463fb2025-04-17 11:22:22 -0700562 m := AgentMessage{
563 Type: ToolUseMessageType,
564 Content: content.Text,
565 ToolResult: content.ToolResult,
566 ToolError: content.ToolError,
567 ToolName: toolName,
568 ToolInput: string(toolInput),
569 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700570 StartTime: content.ToolUseStartTime,
571 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700572 }
573
574 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700575 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
576 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700577 m.Elapsed = &elapsed
578 }
579
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700580 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700581 a.pushToOutbox(ctx, m)
582}
583
584// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700585func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000586 a.mu.Lock()
587 defer a.mu.Unlock()
588 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700589 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
590}
591
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700592// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700593// that need to be displayed (as well as tool calls that we send along when
594// they're done). (It would be reasonable to also mention tool calls when they're
595// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700596func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000597 // Remove the LLM call from outstanding calls
598 a.mu.Lock()
599 delete(a.outstandingLLMCalls, id)
600 a.mu.Unlock()
601
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700602 if resp == nil {
603 // LLM API call failed
604 m := AgentMessage{
605 Type: ErrorMessageType,
606 Content: "API call failed, type 'continue' to try again",
607 }
608 m.SetConvo(convo)
609 a.pushToOutbox(ctx, m)
610 return
611 }
612
Earl Lee2e463fb2025-04-17 11:22:22 -0700613 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700614 if convo.Parent == nil { // subconvos never end the turn
615 switch resp.StopReason {
616 case llm.StopReasonToolUse:
617 // Check whether any of the tool calls are for tools that should end the turn
618 ToolSearch:
619 for _, part := range resp.Content {
620 if part.Type != llm.ContentTypeToolUse {
621 continue
622 }
Sean McCullough021557a2025-05-05 23:20:53 +0000623 // Find the tool by name
624 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700625 if tool.Name == part.ToolName {
626 endOfTurn = tool.EndsTurn
627 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000628 }
629 }
Sean McCullough021557a2025-05-05 23:20:53 +0000630 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700631 default:
632 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000633 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700634 }
635 m := AgentMessage{
636 Type: AgentMessageType,
637 Content: collectTextContent(resp),
638 EndOfTurn: endOfTurn,
639 Usage: &resp.Usage,
640 StartTime: resp.StartTime,
641 EndTime: resp.EndTime,
642 }
643
644 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700645 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700646 var toolCalls []ToolCall
647 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700648 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700649 toolCalls = append(toolCalls, ToolCall{
650 Name: part.ToolName,
651 Input: string(part.ToolInput),
652 ToolCallId: part.ID,
653 })
654 }
655 }
656 m.ToolCalls = toolCalls
657 }
658
659 // Calculate the elapsed time if both start and end times are set
660 if resp.StartTime != nil && resp.EndTime != nil {
661 elapsed := resp.EndTime.Sub(*resp.StartTime)
662 m.Elapsed = &elapsed
663 }
664
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700665 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700666 a.pushToOutbox(ctx, m)
667}
668
669// WorkingDir implements CodingAgent.
670func (a *Agent) WorkingDir() string {
671 return a.workingDir
672}
673
674// MessageCount implements CodingAgent.
675func (a *Agent) MessageCount() int {
676 a.mu.Lock()
677 defer a.mu.Unlock()
678 return len(a.history)
679}
680
681// Messages implements CodingAgent.
682func (a *Agent) Messages(start int, end int) []AgentMessage {
683 a.mu.Lock()
684 defer a.mu.Unlock()
685 return slices.Clone(a.history[start:end])
686}
687
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700688func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700689 return a.originalBudget
690}
691
692// AgentConfig contains configuration for creating a new Agent.
693type AgentConfig struct {
694 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700695 Service llm.Service
696 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700697 GitUsername string
698 GitEmail string
699 SessionID string
700 ClientGOOS string
701 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700702 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700703 UseAnthropicEdit bool
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000704 OneShot bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000705 // Outside information
706 OutsideHostname string
707 OutsideOS string
708 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700709}
710
711// NewAgent creates a new Agent.
712// It is not usable until Init() is called.
713func NewAgent(config AgentConfig) *Agent {
714 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000715 config: config,
716 ready: make(chan struct{}),
717 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700718 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000719 startedAt: time.Now(),
720 originalBudget: config.Budget,
721 seenCommits: make(map[string]bool),
722 outsideHostname: config.OutsideHostname,
723 outsideOS: config.OutsideOS,
724 outsideWorkingDir: config.OutsideWorkingDir,
725 outstandingLLMCalls: make(map[string]struct{}),
726 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700727 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700728 }
729 return agent
730}
731
732type AgentInit struct {
733 WorkingDir string
734 NoGit bool // only for testing
735
736 InDocker bool
737 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000738 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700739 GitRemoteAddr string
740 HostAddr string
741}
742
743func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700744 if a.convo != nil {
745 return fmt.Errorf("Agent.Init: already initialized")
746 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700747 ctx := a.config.Context
748 if ini.InDocker {
749 cmd := exec.CommandContext(ctx, "git", "stash")
750 cmd.Dir = ini.WorkingDir
751 if out, err := cmd.CombinedOutput(); err != nil {
752 return fmt.Errorf("git stash: %s: %v", out, err)
753 }
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700754 // sketch-host is a git repo hosted by "outtie sketch". When it notices a 'git fetch',
755 // it runs "git fetch" underneath the covers to get its latest commits. By configuring
756 // an additional remote.sketch-host.fetch, we make "origin/main" on innie sketch look like
757 // origin/main on outtie sketch, which should make it easier to rebase.
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700758 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
759 cmd.Dir = ini.WorkingDir
760 if out, err := cmd.CombinedOutput(); err != nil {
761 return fmt.Errorf("git remote add: %s: %v", out, err)
762 }
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700763 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.sketch-host.fetch",
764 "+refs/heads/feature/*:refs/remotes/origin/feature/*")
765 cmd.Dir = ini.WorkingDir
766 if out, err := cmd.CombinedOutput(); err != nil {
767 return fmt.Errorf("git config --add: %s: %v", out, err)
768 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000769 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700770 cmd.Dir = ini.WorkingDir
771 if out, err := cmd.CombinedOutput(); err != nil {
772 return fmt.Errorf("git fetch: %s: %w", out, err)
773 }
774 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
775 cmd.Dir = ini.WorkingDir
776 if out, err := cmd.CombinedOutput(); err != nil {
777 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
778 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700779 a.lastHEAD = ini.Commit
780 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000781 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700782 a.initialCommit = ini.Commit
783 if ini.HostAddr != "" {
784 a.url = "http://" + ini.HostAddr
785 }
786 }
787 a.workingDir = ini.WorkingDir
788
789 if !ini.NoGit {
790 repoRoot, err := repoRoot(ctx, a.workingDir)
791 if err != nil {
792 return fmt.Errorf("repoRoot: %w", err)
793 }
794 a.repoRoot = repoRoot
795
796 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
797 if err != nil {
798 return fmt.Errorf("resolveRef: %w", err)
799 }
800 a.initialCommit = commitHash
801
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000802 llmCodeReview := codereview.NoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700803 if experiment.Enabled("llm_review") {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000804 llmCodeReview = codereview.DoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700805 }
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000806 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit, llmCodeReview)
Earl Lee2e463fb2025-04-17 11:22:22 -0700807 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000808 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700809 }
810 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000811
812 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700813 }
814 a.lastHEAD = a.initialCommit
815 a.convo = a.initConvo()
816 close(a.ready)
817 return nil
818}
819
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700820//go:embed agent_system_prompt.txt
821var agentSystemPrompt string
822
Earl Lee2e463fb2025-04-17 11:22:22 -0700823// initConvo initializes the conversation.
824// It must not be called until all agent fields are initialized,
825// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700826func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700827 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700828 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700829 convo.PromptCaching = true
830 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000831 convo.SystemPrompt = a.renderSystemPrompt()
Earl Lee2e463fb2025-04-17 11:22:22 -0700832
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000833 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
834 bashPermissionCheck := func(command string) error {
835 // Check if branch name is set
836 a.mu.Lock()
837 branchSet := a.branchName != ""
838 a.mu.Unlock()
839
840 // If branch is set, all commands are allowed
841 if branchSet {
842 return nil
843 }
844
845 // If branch is not set, check if this is a git commit command
846 willCommit, err := bashkit.WillRunGitCommit(command)
847 if err != nil {
848 // If there's an error checking, we should allow the command to proceed
849 return nil
850 }
851
852 // If it's a git commit and branch is not set, return an error
853 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000854 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000855 }
856
857 return nil
858 }
859
860 // Create a custom bash tool with the permission check
861 bashTool := claudetool.NewBashTool(bashPermissionCheck)
862
Earl Lee2e463fb2025-04-17 11:22:22 -0700863 // Register all tools with the conversation
864 // When adding, removing, or modifying tools here, double-check that the termui tool display
865 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000866
867 var browserTools []*llm.Tool
868 // Add browser tools if enabled
869 // if experiment.Enabled("browser") {
870 if true {
871 bTools, browserCleanup := browse.RegisterBrowserTools(a.config.Context)
872 // Add cleanup function to context cancel
873 go func() {
874 <-a.config.Context.Done()
875 browserCleanup()
876 }()
877 browserTools = bTools
878 }
879
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700880 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000881 bashTool, claudetool.Keyword,
Josh Bleecher Snyder93202652025-05-08 02:05:57 +0000882 claudetool.Think, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000883 a.codereview.Tool(),
884 }
885
886 // One-shot mode is non-interactive, multiple choice requires human response
887 if !a.config.OneShot {
888 convo.Tools = append(convo.Tools, a.multipleChoiceTool())
Earl Lee2e463fb2025-04-17 11:22:22 -0700889 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000890
891 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -0700892 if a.config.UseAnthropicEdit {
893 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
894 } else {
895 convo.Tools = append(convo.Tools, claudetool.Patch)
896 }
897 convo.Listener = a
898 return convo
899}
900
Sean McCullough485afc62025-04-28 14:28:39 -0700901func (a *Agent) multipleChoiceTool() *llm.Tool {
902 ret := &llm.Tool{
903 Name: "multiplechoice",
904 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 +0000905 EndsTurn: true,
Sean McCullough485afc62025-04-28 14:28:39 -0700906 InputSchema: json.RawMessage(`{
907 "type": "object",
908 "description": "The question and a list of answers you would expect the user to choose from.",
909 "properties": {
910 "question": {
911 "type": "string",
912 "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?'"
913 },
914 "responseOptions": {
915 "type": "array",
916 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
917 "items": {
918 "type": "object",
919 "properties": {
920 "caption": {
921 "type": "string",
922 "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'"
923 },
924 "responseText": {
925 "type": "string",
926 "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'"
927 }
928 },
929 "required": ["caption", "responseText"]
930 }
931 }
932 },
933 "required": ["question", "responseOptions"]
934}`),
935 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
936 // The Run logic for "multiplchoice" tool is a no-op on the server.
937 // The UI will present a list of options for the user to select from,
938 // and that's it as far as "executing" the tool_use goes.
939 // When the user *does* select one of the presented options, that
940 // responseText gets sent as a chat message on behalf of the user.
941 return "end your turn and wait for the user to respond", nil
942 },
943 }
944 return ret
945}
946
947type MultipleChoiceOption struct {
948 Caption string `json:"caption"`
949 ResponseText string `json:"responseText"`
950}
951
952type MultipleChoiceParams struct {
953 Question string `json:"question"`
954 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
955}
956
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000957// branchExists reports whether branchName exists, either locally or in well-known remotes.
958func branchExists(dir, branchName string) bool {
959 refs := []string{
960 "refs/heads/",
961 "refs/remotes/origin/",
962 "refs/remotes/sketch-host/",
963 }
964 for _, ref := range refs {
965 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
966 cmd.Dir = dir
967 if cmd.Run() == nil { // exit code 0 means branch exists
968 return true
969 }
970 }
971 return false
972}
973
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000974func (a *Agent) titleTool() *llm.Tool {
975 description := `Sets the conversation title.`
976 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -0700977 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000978 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -0700979 InputSchema: json.RawMessage(`{
980 "type": "object",
981 "properties": {
982 "title": {
983 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000984 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -0700985 }
986 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000987 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -0700988}`),
989 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
990 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000991 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700992 }
993 if err := json.Unmarshal(input, &params); err != nil {
994 return "", err
995 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000996
997 // We don't allow changing the title once set to be consistent with the previous behavior
998 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700999 t := a.Title()
1000 if t != "" {
1001 return "", fmt.Errorf("title already set to: %s", t)
1002 }
1003
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001004 if params.Title == "" {
1005 return "", fmt.Errorf("title parameter cannot be empty")
1006 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001007
1008 a.SetTitle(params.Title)
1009 response := fmt.Sprintf("Title set to %q", params.Title)
1010 return response, nil
1011 },
1012 }
1013 return titleTool
1014}
1015
1016func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001017 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 +00001018 preCommit := &llm.Tool{
1019 Name: "precommit",
1020 Description: description,
1021 InputSchema: json.RawMessage(`{
1022 "type": "object",
1023 "properties": {
1024 "branch_name": {
1025 "type": "string",
1026 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1027 }
1028 },
1029 "required": ["branch_name"]
1030}`),
1031 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
1032 var params struct {
1033 BranchName string `json:"branch_name"`
1034 }
1035 if err := json.Unmarshal(input, &params); err != nil {
1036 return "", err
1037 }
1038
1039 b := a.BranchName()
1040 if b != "" {
1041 return "", fmt.Errorf("branch already set to: %s", b)
1042 }
1043
1044 if params.BranchName == "" {
1045 return "", fmt.Errorf("branch_name parameter cannot be empty")
1046 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001047 if params.BranchName != cleanBranchName(params.BranchName) {
1048 return "", fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
1049 }
1050 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001051 if branchExists(a.workingDir, branchName) {
1052 return "", fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
1053 }
1054
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001055 a.SetBranch(branchName)
1056 response := fmt.Sprintf("Branch name set to %q", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001057
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001058 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1059 if err != nil {
1060 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1061 }
1062 if len(styleHint) > 0 {
1063 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001064 }
1065
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001066 return response, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001067 },
1068 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001069 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001070}
1071
1072func (a *Agent) Ready() <-chan struct{} {
1073 return a.ready
1074}
1075
1076func (a *Agent) UserMessage(ctx context.Context, msg string) {
1077 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1078 a.inbox <- msg
1079}
1080
Sean McCullough485afc62025-04-28 14:28:39 -07001081func (a *Agent) ToolResultMessage(ctx context.Context, toolCallID, msg string) {
1082 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg, ToolCallId: toolCallID})
1083 a.inbox <- msg
1084}
1085
Earl Lee2e463fb2025-04-17 11:22:22 -07001086func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1087 return a.convo.CancelToolUse(toolUseID, cause)
1088}
1089
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001090func (a *Agent) CancelTurn(cause error) {
1091 a.cancelTurnMu.Lock()
1092 defer a.cancelTurnMu.Unlock()
1093 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001094 // Force state transition to cancelled state
1095 ctx := a.config.Context
1096 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001097 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001098 }
1099}
1100
1101func (a *Agent) Loop(ctxOuter context.Context) {
1102 for {
1103 select {
1104 case <-ctxOuter.Done():
1105 return
1106 default:
1107 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001108 a.cancelTurnMu.Lock()
1109 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001110 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001111 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001112 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001113 a.cancelTurn = cancel
1114 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001115 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1116 if err != nil {
1117 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1118 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001119 cancel(nil)
1120 }
1121 }
1122}
1123
1124func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1125 if m.Timestamp.IsZero() {
1126 m.Timestamp = time.Now()
1127 }
1128
1129 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1130 if m.EndOfTurn && m.Type == AgentMessageType {
1131 turnDuration := time.Since(a.startOfTurn)
1132 m.TurnDuration = &turnDuration
1133 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1134 }
1135
Earl Lee2e463fb2025-04-17 11:22:22 -07001136 a.mu.Lock()
1137 defer a.mu.Unlock()
1138 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001139 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001140 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001141
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001142 // Notify all subscribers
1143 for _, ch := range a.subscribers {
1144 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001145 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001146}
1147
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001148func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1149 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001150 if block {
1151 select {
1152 case <-ctx.Done():
1153 return m, ctx.Err()
1154 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001155 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001156 }
1157 }
1158 for {
1159 select {
1160 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001161 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001162 default:
1163 return m, nil
1164 }
1165 }
1166}
1167
Sean McCullough885a16a2025-04-30 02:49:25 +00001168// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001169func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001170 // Reset the start of turn time
1171 a.startOfTurn = time.Now()
1172
Sean McCullough96b60dd2025-04-30 09:49:10 -07001173 // Transition to waiting for user input state
1174 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1175
Sean McCullough885a16a2025-04-30 02:49:25 +00001176 // Process initial user message
1177 initialResp, err := a.processUserMessage(ctx)
1178 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001179 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001180 return err
1181 }
1182
1183 // Handle edge case where both initialResp and err are nil
1184 if initialResp == nil {
1185 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001186 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1187
Sean McCullough9f4b8082025-04-30 17:34:07 +00001188 a.pushToOutbox(ctx, errorMessage(err))
1189 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001190 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001191
Earl Lee2e463fb2025-04-17 11:22:22 -07001192 // We do this as we go, but let's also do it at the end of the turn
1193 defer func() {
1194 if _, err := a.handleGitCommits(ctx); err != nil {
1195 // Just log the error, don't stop execution
1196 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1197 }
1198 }()
1199
Sean McCullougha1e0e492025-05-01 10:51:08 -07001200 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001201 resp := initialResp
1202 for {
1203 // Check if we are over budget
1204 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001205 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001206 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001207 }
1208
1209 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001210 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001211 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001212 break
1213 }
1214
Sean McCullough96b60dd2025-04-30 09:49:10 -07001215 // Transition to tool use requested state
1216 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1217
Sean McCullough885a16a2025-04-30 02:49:25 +00001218 // Handle tool execution
1219 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1220 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001221 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001222 }
1223
Sean McCullougha1e0e492025-05-01 10:51:08 -07001224 if toolResp == nil {
1225 return fmt.Errorf("cannot continue conversation with a nil tool response")
1226 }
1227
Sean McCullough885a16a2025-04-30 02:49:25 +00001228 // Set the response for the next iteration
1229 resp = toolResp
1230 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001231
1232 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001233}
1234
1235// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001236func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001237 // Wait for at least one message from the user
1238 msgs, err := a.GatherMessages(ctx, true)
1239 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001240 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001241 return nil, err
1242 }
1243
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001244 userMessage := llm.Message{
1245 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001246 Content: msgs,
1247 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001248
Sean McCullough96b60dd2025-04-30 09:49:10 -07001249 // Transition to sending to LLM state
1250 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1251
Sean McCullough885a16a2025-04-30 02:49:25 +00001252 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001253 resp, err := a.convo.SendMessage(userMessage)
1254 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001255 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001256 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001257 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001258 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001259
Sean McCullough96b60dd2025-04-30 09:49:10 -07001260 // Transition to processing LLM response state
1261 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1262
Sean McCullough885a16a2025-04-30 02:49:25 +00001263 return resp, nil
1264}
1265
1266// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001267func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1268 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001269 cancelled := false
1270
Sean McCullough96b60dd2025-04-30 09:49:10 -07001271 // Transition to checking for cancellation state
1272 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1273
Sean McCullough885a16a2025-04-30 02:49:25 +00001274 // Check if the operation was cancelled by the user
1275 select {
1276 case <-ctx.Done():
1277 // Don't actually run any of the tools, but rather build a response
1278 // for each tool_use message letting the LLM know that user canceled it.
1279 var err error
1280 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001281 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001282 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001283 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001284 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001285 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001286 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001287 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001288 // Transition to running tool state
1289 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1290
Sean McCullough885a16a2025-04-30 02:49:25 +00001291 // Add working directory to context for tool execution
1292 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1293
1294 // Execute the tools
1295 var err error
1296 results, err = a.convo.ToolResultContents(ctx, resp)
1297 if ctx.Err() != nil { // e.g. the user canceled the operation
1298 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001299 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001300 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001301 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001302 a.pushToOutbox(ctx, errorMessage(err))
1303 }
1304 }
1305
1306 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001307 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001308 autoqualityMessages := a.processGitChanges(ctx)
1309
1310 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001311 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001312 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001313 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001314 return false, nil
1315 }
1316
1317 // Continue the conversation with tool results and any user messages
1318 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1319}
1320
1321// processGitChanges checks for new git commits and runs autoformatters if needed
1322func (a *Agent) processGitChanges(ctx context.Context) []string {
1323 // Check for git commits after tool execution
1324 newCommits, err := a.handleGitCommits(ctx)
1325 if err != nil {
1326 // Just log the error, don't stop execution
1327 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1328 return nil
1329 }
1330
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001331 // Run mechanical checks if there was exactly one new commit.
1332 if len(newCommits) != 1 {
1333 return nil
1334 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001335 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001336 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1337 msg := a.codereview.RunMechanicalChecks(ctx)
1338 if msg != "" {
1339 a.pushToOutbox(ctx, AgentMessage{
1340 Type: AutoMessageType,
1341 Content: msg,
1342 Timestamp: time.Now(),
1343 })
1344 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001345 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001346
1347 return autoqualityMessages
1348}
1349
1350// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001351func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001352 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001353 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001354 msgs, err := a.GatherMessages(ctx, false)
1355 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001356 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001357 return false, nil
1358 }
1359
1360 // Inject any auto-generated messages from quality checks
1361 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001362 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001363 }
1364
1365 // Handle cancellation by appending a message about it
1366 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001367 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001368 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001369 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001370 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1371 } else if err := a.convo.OverBudget(); err != nil {
1372 // Handle budget issues by appending a message about it
1373 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 -07001374 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001375 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1376 }
1377
1378 // Combine tool results with user messages
1379 results = append(results, msgs...)
1380
1381 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001382 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001383 resp, err := a.convo.SendMessage(llm.Message{
1384 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001385 Content: results,
1386 })
1387 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001388 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001389 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1390 return true, nil // Return true to continue the conversation, but with no response
1391 }
1392
Sean McCullough96b60dd2025-04-30 09:49:10 -07001393 // Transition back to processing LLM response
1394 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1395
Sean McCullough885a16a2025-04-30 02:49:25 +00001396 if cancelled {
1397 return false, nil
1398 }
1399
1400 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001401}
1402
1403func (a *Agent) overBudget(ctx context.Context) error {
1404 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001405 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001406 m := budgetMessage(err)
1407 m.Content = m.Content + "\n\nBudget reset."
1408 a.pushToOutbox(ctx, budgetMessage(err))
1409 a.convo.ResetBudget(a.originalBudget)
1410 return err
1411 }
1412 return nil
1413}
1414
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001415func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001416 // Collect all text content
1417 var allText strings.Builder
1418 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001419 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001420 if allText.Len() > 0 {
1421 allText.WriteString("\n\n")
1422 }
1423 allText.WriteString(content.Text)
1424 }
1425 }
1426 return allText.String()
1427}
1428
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001429func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001430 a.mu.Lock()
1431 defer a.mu.Unlock()
1432 return a.convo.CumulativeUsage()
1433}
1434
Earl Lee2e463fb2025-04-17 11:22:22 -07001435// Diff returns a unified diff of changes made since the agent was instantiated.
1436func (a *Agent) Diff(commit *string) (string, error) {
1437 if a.initialCommit == "" {
1438 return "", fmt.Errorf("no initial commit reference available")
1439 }
1440
1441 // Find the repository root
1442 ctx := context.Background()
1443
1444 // If a specific commit hash is provided, show just that commit's changes
1445 if commit != nil && *commit != "" {
1446 // Validate that the commit looks like a valid git SHA
1447 if !isValidGitSHA(*commit) {
1448 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1449 }
1450
1451 // Get the diff for just this commit
1452 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1453 cmd.Dir = a.repoRoot
1454 output, err := cmd.CombinedOutput()
1455 if err != nil {
1456 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1457 }
1458 return string(output), nil
1459 }
1460
1461 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1462 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1463 cmd.Dir = a.repoRoot
1464 output, err := cmd.CombinedOutput()
1465 if err != nil {
1466 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1467 }
1468
1469 return string(output), nil
1470}
1471
1472// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1473func (a *Agent) InitialCommit() string {
1474 return a.initialCommit
1475}
1476
1477// handleGitCommits() highlights new commits to the user. When running
1478// under docker, new HEADs are pushed to a branch according to the title.
1479func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1480 if a.repoRoot == "" {
1481 return nil, nil
1482 }
1483
1484 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1485 if err != nil {
1486 return nil, err
1487 }
1488 if head == a.lastHEAD {
1489 return nil, nil // nothing to do
1490 }
1491 defer func() {
1492 a.lastHEAD = head
1493 }()
1494
1495 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1496 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1497 // to the last 100 commits.
1498 var commits []*GitCommit
1499
1500 // Get commits since the initial commit
1501 // Format: <hash>\0<subject>\0<body>\0
1502 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1503 // Limit to 100 commits to avoid overwhelming the user
1504 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1505 cmd.Dir = a.repoRoot
1506 output, err := cmd.Output()
1507 if err != nil {
1508 return nil, fmt.Errorf("failed to get git log: %w", err)
1509 }
1510
1511 // Parse git log output and filter out already seen commits
1512 parsedCommits := parseGitLog(string(output))
1513
1514 var headCommit *GitCommit
1515
1516 // Filter out commits we've already seen
1517 for _, commit := range parsedCommits {
1518 if commit.Hash == head {
1519 headCommit = &commit
1520 }
1521
1522 // Skip if we've seen this commit before. If our head has changed, always include that.
1523 if a.seenCommits[commit.Hash] && commit.Hash != head {
1524 continue
1525 }
1526
1527 // Mark this commit as seen
1528 a.seenCommits[commit.Hash] = true
1529
1530 // Add to our list of new commits
1531 commits = append(commits, &commit)
1532 }
1533
1534 if a.gitRemoteAddr != "" {
1535 if headCommit == nil {
1536 // I think this can only happen if we have a bug or if there's a race.
1537 headCommit = &GitCommit{}
1538 headCommit.Hash = head
1539 headCommit.Subject = "unknown"
1540 commits = append(commits, headCommit)
1541 }
1542
Philip Zeyliger113e2052025-05-09 21:59:40 +00001543 originalBranch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
1544 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001545
1546 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1547 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1548 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001549
1550 // Try up to 10 times with different branch names if the branch is checked out on the remote
1551 var out []byte
1552 var err error
1553 for retries := range 10 {
1554 if retries > 0 {
1555 // Add a numeric suffix to the branch name
1556 branch = fmt.Sprintf("%s%d", originalBranch, retries)
1557 }
1558
1559 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1560 cmd.Dir = a.workingDir
1561 out, err = cmd.CombinedOutput()
1562
1563 if err == nil {
1564 // Success! Break out of the retry loop
1565 break
1566 }
1567
1568 // Check if this is the "refusing to update checked out branch" error
1569 if !strings.Contains(string(out), "refusing to update checked out branch") {
1570 // This is a different error, so don't retry
1571 break
1572 }
1573
1574 // If we're on the last retry, we'll report the error
1575 if retries == 9 {
1576 break
1577 }
1578 }
1579
1580 if err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001581 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1582 } else {
1583 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001584 // Update the agent's branch name if we ended up using a different one
1585 if branch != originalBranch {
1586 a.branchName = branch
1587 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001588 }
1589 }
1590
1591 // If we found new commits, create a message
1592 if len(commits) > 0 {
1593 msg := AgentMessage{
1594 Type: CommitMessageType,
1595 Timestamp: time.Now(),
1596 Commits: commits,
1597 }
1598 a.pushToOutbox(ctx, msg)
1599 }
1600 return commits, nil
1601}
1602
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001603func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001604 return strings.Map(func(r rune) rune {
1605 // lowercase
1606 if r >= 'A' && r <= 'Z' {
1607 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001608 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001609 // replace spaces with dashes
1610 if r == ' ' {
1611 return '-'
1612 }
1613 // allow alphanumerics and dashes
1614 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1615 return r
1616 }
1617 return -1
1618 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001619}
1620
1621// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1622// and returns an array of GitCommit structs.
1623func parseGitLog(output string) []GitCommit {
1624 var commits []GitCommit
1625
1626 // No output means no commits
1627 if len(output) == 0 {
1628 return commits
1629 }
1630
1631 // Split by NULL byte
1632 parts := strings.Split(output, "\x00")
1633
1634 // Process in triplets (hash, subject, body)
1635 for i := 0; i < len(parts); i++ {
1636 // Skip empty parts
1637 if parts[i] == "" {
1638 continue
1639 }
1640
1641 // This should be a hash
1642 hash := strings.TrimSpace(parts[i])
1643
1644 // Make sure we have at least a subject part available
1645 if i+1 >= len(parts) {
1646 break // No more parts available
1647 }
1648
1649 // Get the subject
1650 subject := strings.TrimSpace(parts[i+1])
1651
1652 // Get the body if available
1653 body := ""
1654 if i+2 < len(parts) {
1655 body = strings.TrimSpace(parts[i+2])
1656 }
1657
1658 // Skip to the next triplet
1659 i += 2
1660
1661 commits = append(commits, GitCommit{
1662 Hash: hash,
1663 Subject: subject,
1664 Body: body,
1665 })
1666 }
1667
1668 return commits
1669}
1670
1671func repoRoot(ctx context.Context, dir string) (string, error) {
1672 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1673 stderr := new(strings.Builder)
1674 cmd.Stderr = stderr
1675 cmd.Dir = dir
1676 out, err := cmd.Output()
1677 if err != nil {
1678 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1679 }
1680 return strings.TrimSpace(string(out)), nil
1681}
1682
1683func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1684 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1685 stderr := new(strings.Builder)
1686 cmd.Stderr = stderr
1687 cmd.Dir = dir
1688 out, err := cmd.Output()
1689 if err != nil {
1690 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1691 }
1692 // TODO: validate that out is valid hex
1693 return strings.TrimSpace(string(out)), nil
1694}
1695
1696// isValidGitSHA validates if a string looks like a valid git SHA hash.
1697// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1698func isValidGitSHA(sha string) bool {
1699 // Git SHA must be a hexadecimal string with at least 4 characters
1700 if len(sha) < 4 || len(sha) > 40 {
1701 return false
1702 }
1703
1704 // Check if the string only contains hexadecimal characters
1705 for _, char := range sha {
1706 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1707 return false
1708 }
1709 }
1710
1711 return true
1712}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001713
1714// getGitOrigin returns the URL of the git remote 'origin' if it exists
1715func getGitOrigin(ctx context.Context, dir string) string {
1716 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1717 cmd.Dir = dir
1718 stderr := new(strings.Builder)
1719 cmd.Stderr = stderr
1720 out, err := cmd.Output()
1721 if err != nil {
1722 return ""
1723 }
1724 return strings.TrimSpace(string(out))
1725}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001726
1727func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1728 cmd := exec.CommandContext(ctx, "git", "stash")
1729 cmd.Dir = workingDir
1730 if out, err := cmd.CombinedOutput(); err != nil {
1731 return fmt.Errorf("git stash: %s: %v", out, err)
1732 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001733 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001734 cmd.Dir = workingDir
1735 if out, err := cmd.CombinedOutput(); err != nil {
1736 return fmt.Errorf("git fetch: %s: %w", out, err)
1737 }
1738 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1739 cmd.Dir = workingDir
1740 if out, err := cmd.CombinedOutput(); err != nil {
1741 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1742 }
1743 a.lastHEAD = revision
1744 a.initialCommit = revision
1745 return nil
1746}
1747
1748func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1749 a.mu.Lock()
1750 a.title = ""
1751 a.firstMessageIndex = len(a.history)
1752 a.convo = a.initConvo()
1753 gitReset := func() error {
1754 if a.config.InDocker && rev != "" {
1755 err := a.initGitRevision(ctx, a.workingDir, rev)
1756 if err != nil {
1757 return err
1758 }
1759 } else if !a.config.InDocker && rev != "" {
1760 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1761 }
1762 return nil
1763 }
1764 err := gitReset()
1765 a.mu.Unlock()
1766 if err != nil {
1767 a.pushToOutbox(a.config.Context, errorMessage(err))
1768 }
1769
1770 a.pushToOutbox(a.config.Context, AgentMessage{
1771 Type: AgentMessageType, Content: "Conversation restarted.",
1772 })
1773 if initialPrompt != "" {
1774 a.UserMessage(ctx, initialPrompt)
1775 }
1776 return nil
1777}
1778
1779func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1780 msg := `The user has requested a suggestion for a re-prompt.
1781
1782 Given the current conversation thus far, suggest a re-prompt that would
1783 capture the instructions and feedback so far, as well as any
1784 research or other information that would be helpful in implementing
1785 the task.
1786
1787 Reply with ONLY the reprompt text.
1788 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001789 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001790 // By doing this in a subconversation, the agent doesn't call tools (because
1791 // there aren't any), and there's not a concurrency risk with on-going other
1792 // outstanding conversations.
1793 convo := a.convo.SubConvoWithHistory()
1794 resp, err := convo.SendMessage(userMessage)
1795 if err != nil {
1796 a.pushToOutbox(ctx, errorMessage(err))
1797 return "", err
1798 }
1799 textContent := collectTextContent(resp)
1800 return textContent, nil
1801}
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001802
1803// systemPromptData contains the data used to render the system prompt template
1804type systemPromptData struct {
1805 EditPrompt string
1806 ClientGOOS string
1807 ClientGOARCH string
1808 WorkingDir string
1809 RepoRoot string
1810 InitialCommit string
1811}
1812
1813// renderSystemPrompt renders the system prompt template.
1814func (a *Agent) renderSystemPrompt() string {
1815 // Determine the appropriate edit prompt based on config
1816 var editPrompt string
1817 if a.config.UseAnthropicEdit {
1818 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."
1819 } else {
1820 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1821 }
1822
1823 data := systemPromptData{
1824 EditPrompt: editPrompt,
1825 ClientGOOS: a.config.ClientGOOS,
1826 ClientGOARCH: a.config.ClientGOARCH,
1827 WorkingDir: a.workingDir,
1828 RepoRoot: a.repoRoot,
1829 InitialCommit: a.initialCommit,
1830 }
1831
1832 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1833 if err != nil {
1834 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1835 }
1836 buf := new(strings.Builder)
1837 err = tmpl.Execute(buf, data)
1838 if err != nil {
1839 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1840 }
1841 return buf.String()
1842}