blob: 1a3f85101e4cdfbc8e3426fca524ad1003c3e004 [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 Zeyligerd0ac1ea2025-04-21 20:04:19 -0700754 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
755 cmd.Dir = ini.WorkingDir
756 if out, err := cmd.CombinedOutput(); err != nil {
757 return fmt.Errorf("git remote add: %s: %v", out, err)
758 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000759 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700760 cmd.Dir = ini.WorkingDir
761 if out, err := cmd.CombinedOutput(); err != nil {
762 return fmt.Errorf("git fetch: %s: %w", out, err)
763 }
764 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
765 cmd.Dir = ini.WorkingDir
766 if out, err := cmd.CombinedOutput(); err != nil {
767 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
768 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700769 a.lastHEAD = ini.Commit
770 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000771 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700772 a.initialCommit = ini.Commit
773 if ini.HostAddr != "" {
774 a.url = "http://" + ini.HostAddr
775 }
776 }
777 a.workingDir = ini.WorkingDir
778
779 if !ini.NoGit {
780 repoRoot, err := repoRoot(ctx, a.workingDir)
781 if err != nil {
782 return fmt.Errorf("repoRoot: %w", err)
783 }
784 a.repoRoot = repoRoot
785
786 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
787 if err != nil {
788 return fmt.Errorf("resolveRef: %w", err)
789 }
790 a.initialCommit = commitHash
791
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000792 llmCodeReview := codereview.NoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700793 if experiment.Enabled("llm_review") {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000794 llmCodeReview = codereview.DoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700795 }
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000796 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit, llmCodeReview)
Earl Lee2e463fb2025-04-17 11:22:22 -0700797 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000798 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700799 }
800 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000801
802 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700803 }
804 a.lastHEAD = a.initialCommit
805 a.convo = a.initConvo()
806 close(a.ready)
807 return nil
808}
809
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700810//go:embed agent_system_prompt.txt
811var agentSystemPrompt string
812
Earl Lee2e463fb2025-04-17 11:22:22 -0700813// initConvo initializes the conversation.
814// It must not be called until all agent fields are initialized,
815// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700816func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700817 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700818 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700819 convo.PromptCaching = true
820 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000821 convo.SystemPrompt = a.renderSystemPrompt()
Earl Lee2e463fb2025-04-17 11:22:22 -0700822
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000823 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
824 bashPermissionCheck := func(command string) error {
825 // Check if branch name is set
826 a.mu.Lock()
827 branchSet := a.branchName != ""
828 a.mu.Unlock()
829
830 // If branch is set, all commands are allowed
831 if branchSet {
832 return nil
833 }
834
835 // If branch is not set, check if this is a git commit command
836 willCommit, err := bashkit.WillRunGitCommit(command)
837 if err != nil {
838 // If there's an error checking, we should allow the command to proceed
839 return nil
840 }
841
842 // If it's a git commit and branch is not set, return an error
843 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000844 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000845 }
846
847 return nil
848 }
849
850 // Create a custom bash tool with the permission check
851 bashTool := claudetool.NewBashTool(bashPermissionCheck)
852
Earl Lee2e463fb2025-04-17 11:22:22 -0700853 // Register all tools with the conversation
854 // When adding, removing, or modifying tools here, double-check that the termui tool display
855 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000856
857 var browserTools []*llm.Tool
858 // Add browser tools if enabled
859 // if experiment.Enabled("browser") {
860 if true {
861 bTools, browserCleanup := browse.RegisterBrowserTools(a.config.Context)
862 // Add cleanup function to context cancel
863 go func() {
864 <-a.config.Context.Done()
865 browserCleanup()
866 }()
867 browserTools = bTools
868 }
869
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700870 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000871 bashTool, claudetool.Keyword,
Josh Bleecher Snyder93202652025-05-08 02:05:57 +0000872 claudetool.Think, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000873 a.codereview.Tool(),
874 }
875
876 // One-shot mode is non-interactive, multiple choice requires human response
877 if !a.config.OneShot {
878 convo.Tools = append(convo.Tools, a.multipleChoiceTool())
Earl Lee2e463fb2025-04-17 11:22:22 -0700879 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000880
881 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -0700882 if a.config.UseAnthropicEdit {
883 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
884 } else {
885 convo.Tools = append(convo.Tools, claudetool.Patch)
886 }
887 convo.Listener = a
888 return convo
889}
890
Sean McCullough485afc62025-04-28 14:28:39 -0700891func (a *Agent) multipleChoiceTool() *llm.Tool {
892 ret := &llm.Tool{
893 Name: "multiplechoice",
894 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 +0000895 EndsTurn: true,
Sean McCullough485afc62025-04-28 14:28:39 -0700896 InputSchema: json.RawMessage(`{
897 "type": "object",
898 "description": "The question and a list of answers you would expect the user to choose from.",
899 "properties": {
900 "question": {
901 "type": "string",
902 "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?'"
903 },
904 "responseOptions": {
905 "type": "array",
906 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
907 "items": {
908 "type": "object",
909 "properties": {
910 "caption": {
911 "type": "string",
912 "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'"
913 },
914 "responseText": {
915 "type": "string",
916 "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'"
917 }
918 },
919 "required": ["caption", "responseText"]
920 }
921 }
922 },
923 "required": ["question", "responseOptions"]
924}`),
925 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
926 // The Run logic for "multiplchoice" tool is a no-op on the server.
927 // The UI will present a list of options for the user to select from,
928 // and that's it as far as "executing" the tool_use goes.
929 // When the user *does* select one of the presented options, that
930 // responseText gets sent as a chat message on behalf of the user.
931 return "end your turn and wait for the user to respond", nil
932 },
933 }
934 return ret
935}
936
937type MultipleChoiceOption struct {
938 Caption string `json:"caption"`
939 ResponseText string `json:"responseText"`
940}
941
942type MultipleChoiceParams struct {
943 Question string `json:"question"`
944 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
945}
946
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000947// branchExists reports whether branchName exists, either locally or in well-known remotes.
948func branchExists(dir, branchName string) bool {
949 refs := []string{
950 "refs/heads/",
951 "refs/remotes/origin/",
952 "refs/remotes/sketch-host/",
953 }
954 for _, ref := range refs {
955 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
956 cmd.Dir = dir
957 if cmd.Run() == nil { // exit code 0 means branch exists
958 return true
959 }
960 }
961 return false
962}
963
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000964func (a *Agent) titleTool() *llm.Tool {
965 description := `Sets the conversation title.`
966 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -0700967 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000968 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -0700969 InputSchema: json.RawMessage(`{
970 "type": "object",
971 "properties": {
972 "title": {
973 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000974 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -0700975 }
976 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000977 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -0700978}`),
979 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
980 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000981 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700982 }
983 if err := json.Unmarshal(input, &params); err != nil {
984 return "", err
985 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000986
987 // We don't allow changing the title once set to be consistent with the previous behavior
988 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700989 t := a.Title()
990 if t != "" {
991 return "", fmt.Errorf("title already set to: %s", t)
992 }
993
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700994 if params.Title == "" {
995 return "", fmt.Errorf("title parameter cannot be empty")
996 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000997
998 a.SetTitle(params.Title)
999 response := fmt.Sprintf("Title set to %q", params.Title)
1000 return response, nil
1001 },
1002 }
1003 return titleTool
1004}
1005
1006func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001007 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 +00001008 preCommit := &llm.Tool{
1009 Name: "precommit",
1010 Description: description,
1011 InputSchema: json.RawMessage(`{
1012 "type": "object",
1013 "properties": {
1014 "branch_name": {
1015 "type": "string",
1016 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1017 }
1018 },
1019 "required": ["branch_name"]
1020}`),
1021 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
1022 var params struct {
1023 BranchName string `json:"branch_name"`
1024 }
1025 if err := json.Unmarshal(input, &params); err != nil {
1026 return "", err
1027 }
1028
1029 b := a.BranchName()
1030 if b != "" {
1031 return "", fmt.Errorf("branch already set to: %s", b)
1032 }
1033
1034 if params.BranchName == "" {
1035 return "", fmt.Errorf("branch_name parameter cannot be empty")
1036 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001037 if params.BranchName != cleanBranchName(params.BranchName) {
1038 return "", fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
1039 }
1040 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001041 if branchExists(a.workingDir, branchName) {
1042 return "", fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
1043 }
1044
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001045 a.SetBranch(branchName)
1046 response := fmt.Sprintf("Branch name set to %q", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001047
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001048 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1049 if err != nil {
1050 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1051 }
1052 if len(styleHint) > 0 {
1053 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001054 }
1055
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001056 return response, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001057 },
1058 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001059 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001060}
1061
1062func (a *Agent) Ready() <-chan struct{} {
1063 return a.ready
1064}
1065
1066func (a *Agent) UserMessage(ctx context.Context, msg string) {
1067 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1068 a.inbox <- msg
1069}
1070
Sean McCullough485afc62025-04-28 14:28:39 -07001071func (a *Agent) ToolResultMessage(ctx context.Context, toolCallID, msg string) {
1072 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg, ToolCallId: toolCallID})
1073 a.inbox <- msg
1074}
1075
Earl Lee2e463fb2025-04-17 11:22:22 -07001076func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1077 return a.convo.CancelToolUse(toolUseID, cause)
1078}
1079
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001080func (a *Agent) CancelTurn(cause error) {
1081 a.cancelTurnMu.Lock()
1082 defer a.cancelTurnMu.Unlock()
1083 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001084 // Force state transition to cancelled state
1085 ctx := a.config.Context
1086 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001087 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001088 }
1089}
1090
1091func (a *Agent) Loop(ctxOuter context.Context) {
1092 for {
1093 select {
1094 case <-ctxOuter.Done():
1095 return
1096 default:
1097 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001098 a.cancelTurnMu.Lock()
1099 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001100 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001101 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001102 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001103 a.cancelTurn = cancel
1104 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001105 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1106 if err != nil {
1107 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1108 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001109 cancel(nil)
1110 }
1111 }
1112}
1113
1114func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1115 if m.Timestamp.IsZero() {
1116 m.Timestamp = time.Now()
1117 }
1118
1119 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1120 if m.EndOfTurn && m.Type == AgentMessageType {
1121 turnDuration := time.Since(a.startOfTurn)
1122 m.TurnDuration = &turnDuration
1123 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1124 }
1125
Earl Lee2e463fb2025-04-17 11:22:22 -07001126 a.mu.Lock()
1127 defer a.mu.Unlock()
1128 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001129 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001130 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001131
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001132 // Notify all subscribers
1133 for _, ch := range a.subscribers {
1134 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001135 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001136}
1137
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001138func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1139 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001140 if block {
1141 select {
1142 case <-ctx.Done():
1143 return m, ctx.Err()
1144 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001145 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001146 }
1147 }
1148 for {
1149 select {
1150 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001151 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001152 default:
1153 return m, nil
1154 }
1155 }
1156}
1157
Sean McCullough885a16a2025-04-30 02:49:25 +00001158// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001159func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001160 // Reset the start of turn time
1161 a.startOfTurn = time.Now()
1162
Sean McCullough96b60dd2025-04-30 09:49:10 -07001163 // Transition to waiting for user input state
1164 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1165
Sean McCullough885a16a2025-04-30 02:49:25 +00001166 // Process initial user message
1167 initialResp, err := a.processUserMessage(ctx)
1168 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001169 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001170 return err
1171 }
1172
1173 // Handle edge case where both initialResp and err are nil
1174 if initialResp == nil {
1175 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001176 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1177
Sean McCullough9f4b8082025-04-30 17:34:07 +00001178 a.pushToOutbox(ctx, errorMessage(err))
1179 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001180 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001181
Earl Lee2e463fb2025-04-17 11:22:22 -07001182 // We do this as we go, but let's also do it at the end of the turn
1183 defer func() {
1184 if _, err := a.handleGitCommits(ctx); err != nil {
1185 // Just log the error, don't stop execution
1186 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1187 }
1188 }()
1189
Sean McCullougha1e0e492025-05-01 10:51:08 -07001190 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001191 resp := initialResp
1192 for {
1193 // Check if we are over budget
1194 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001195 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001196 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001197 }
1198
1199 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001200 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001201 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001202 break
1203 }
1204
Sean McCullough96b60dd2025-04-30 09:49:10 -07001205 // Transition to tool use requested state
1206 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1207
Sean McCullough885a16a2025-04-30 02:49:25 +00001208 // Handle tool execution
1209 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1210 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001211 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001212 }
1213
Sean McCullougha1e0e492025-05-01 10:51:08 -07001214 if toolResp == nil {
1215 return fmt.Errorf("cannot continue conversation with a nil tool response")
1216 }
1217
Sean McCullough885a16a2025-04-30 02:49:25 +00001218 // Set the response for the next iteration
1219 resp = toolResp
1220 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001221
1222 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001223}
1224
1225// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001226func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001227 // Wait for at least one message from the user
1228 msgs, err := a.GatherMessages(ctx, true)
1229 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001230 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001231 return nil, err
1232 }
1233
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001234 userMessage := llm.Message{
1235 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001236 Content: msgs,
1237 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001238
Sean McCullough96b60dd2025-04-30 09:49:10 -07001239 // Transition to sending to LLM state
1240 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1241
Sean McCullough885a16a2025-04-30 02:49:25 +00001242 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001243 resp, err := a.convo.SendMessage(userMessage)
1244 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001245 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001246 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001247 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001248 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001249
Sean McCullough96b60dd2025-04-30 09:49:10 -07001250 // Transition to processing LLM response state
1251 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1252
Sean McCullough885a16a2025-04-30 02:49:25 +00001253 return resp, nil
1254}
1255
1256// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001257func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1258 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001259 cancelled := false
1260
Sean McCullough96b60dd2025-04-30 09:49:10 -07001261 // Transition to checking for cancellation state
1262 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1263
Sean McCullough885a16a2025-04-30 02:49:25 +00001264 // Check if the operation was cancelled by the user
1265 select {
1266 case <-ctx.Done():
1267 // Don't actually run any of the tools, but rather build a response
1268 // for each tool_use message letting the LLM know that user canceled it.
1269 var err error
1270 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001271 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001272 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001273 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001274 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001275 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001276 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001277 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001278 // Transition to running tool state
1279 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1280
Sean McCullough885a16a2025-04-30 02:49:25 +00001281 // Add working directory to context for tool execution
1282 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1283
1284 // Execute the tools
1285 var err error
1286 results, err = a.convo.ToolResultContents(ctx, resp)
1287 if ctx.Err() != nil { // e.g. the user canceled the operation
1288 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001289 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001290 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001291 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001292 a.pushToOutbox(ctx, errorMessage(err))
1293 }
1294 }
1295
1296 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001297 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001298 autoqualityMessages := a.processGitChanges(ctx)
1299
1300 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001301 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001302 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001303 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001304 return false, nil
1305 }
1306
1307 // Continue the conversation with tool results and any user messages
1308 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1309}
1310
1311// processGitChanges checks for new git commits and runs autoformatters if needed
1312func (a *Agent) processGitChanges(ctx context.Context) []string {
1313 // Check for git commits after tool execution
1314 newCommits, err := a.handleGitCommits(ctx)
1315 if err != nil {
1316 // Just log the error, don't stop execution
1317 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1318 return nil
1319 }
1320
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001321 // Run mechanical checks if there was exactly one new commit.
1322 if len(newCommits) != 1 {
1323 return nil
1324 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001325 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001326 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1327 msg := a.codereview.RunMechanicalChecks(ctx)
1328 if msg != "" {
1329 a.pushToOutbox(ctx, AgentMessage{
1330 Type: AutoMessageType,
1331 Content: msg,
1332 Timestamp: time.Now(),
1333 })
1334 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001335 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001336
1337 return autoqualityMessages
1338}
1339
1340// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001341func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001342 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001343 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001344 msgs, err := a.GatherMessages(ctx, false)
1345 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001346 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001347 return false, nil
1348 }
1349
1350 // Inject any auto-generated messages from quality checks
1351 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001352 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001353 }
1354
1355 // Handle cancellation by appending a message about it
1356 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001357 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001358 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001359 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001360 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1361 } else if err := a.convo.OverBudget(); err != nil {
1362 // Handle budget issues by appending a message about it
1363 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 -07001364 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001365 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1366 }
1367
1368 // Combine tool results with user messages
1369 results = append(results, msgs...)
1370
1371 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001372 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001373 resp, err := a.convo.SendMessage(llm.Message{
1374 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001375 Content: results,
1376 })
1377 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001378 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001379 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1380 return true, nil // Return true to continue the conversation, but with no response
1381 }
1382
Sean McCullough96b60dd2025-04-30 09:49:10 -07001383 // Transition back to processing LLM response
1384 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1385
Sean McCullough885a16a2025-04-30 02:49:25 +00001386 if cancelled {
1387 return false, nil
1388 }
1389
1390 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001391}
1392
1393func (a *Agent) overBudget(ctx context.Context) error {
1394 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001395 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001396 m := budgetMessage(err)
1397 m.Content = m.Content + "\n\nBudget reset."
1398 a.pushToOutbox(ctx, budgetMessage(err))
1399 a.convo.ResetBudget(a.originalBudget)
1400 return err
1401 }
1402 return nil
1403}
1404
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001405func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001406 // Collect all text content
1407 var allText strings.Builder
1408 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001409 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001410 if allText.Len() > 0 {
1411 allText.WriteString("\n\n")
1412 }
1413 allText.WriteString(content.Text)
1414 }
1415 }
1416 return allText.String()
1417}
1418
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001419func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001420 a.mu.Lock()
1421 defer a.mu.Unlock()
1422 return a.convo.CumulativeUsage()
1423}
1424
Earl Lee2e463fb2025-04-17 11:22:22 -07001425// Diff returns a unified diff of changes made since the agent was instantiated.
1426func (a *Agent) Diff(commit *string) (string, error) {
1427 if a.initialCommit == "" {
1428 return "", fmt.Errorf("no initial commit reference available")
1429 }
1430
1431 // Find the repository root
1432 ctx := context.Background()
1433
1434 // If a specific commit hash is provided, show just that commit's changes
1435 if commit != nil && *commit != "" {
1436 // Validate that the commit looks like a valid git SHA
1437 if !isValidGitSHA(*commit) {
1438 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1439 }
1440
1441 // Get the diff for just this commit
1442 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1443 cmd.Dir = a.repoRoot
1444 output, err := cmd.CombinedOutput()
1445 if err != nil {
1446 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1447 }
1448 return string(output), nil
1449 }
1450
1451 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1452 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1453 cmd.Dir = a.repoRoot
1454 output, err := cmd.CombinedOutput()
1455 if err != nil {
1456 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1457 }
1458
1459 return string(output), nil
1460}
1461
1462// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1463func (a *Agent) InitialCommit() string {
1464 return a.initialCommit
1465}
1466
1467// handleGitCommits() highlights new commits to the user. When running
1468// under docker, new HEADs are pushed to a branch according to the title.
1469func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1470 if a.repoRoot == "" {
1471 return nil, nil
1472 }
1473
1474 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1475 if err != nil {
1476 return nil, err
1477 }
1478 if head == a.lastHEAD {
1479 return nil, nil // nothing to do
1480 }
1481 defer func() {
1482 a.lastHEAD = head
1483 }()
1484
1485 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1486 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1487 // to the last 100 commits.
1488 var commits []*GitCommit
1489
1490 // Get commits since the initial commit
1491 // Format: <hash>\0<subject>\0<body>\0
1492 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1493 // Limit to 100 commits to avoid overwhelming the user
1494 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1495 cmd.Dir = a.repoRoot
1496 output, err := cmd.Output()
1497 if err != nil {
1498 return nil, fmt.Errorf("failed to get git log: %w", err)
1499 }
1500
1501 // Parse git log output and filter out already seen commits
1502 parsedCommits := parseGitLog(string(output))
1503
1504 var headCommit *GitCommit
1505
1506 // Filter out commits we've already seen
1507 for _, commit := range parsedCommits {
1508 if commit.Hash == head {
1509 headCommit = &commit
1510 }
1511
1512 // Skip if we've seen this commit before. If our head has changed, always include that.
1513 if a.seenCommits[commit.Hash] && commit.Hash != head {
1514 continue
1515 }
1516
1517 // Mark this commit as seen
1518 a.seenCommits[commit.Hash] = true
1519
1520 // Add to our list of new commits
1521 commits = append(commits, &commit)
1522 }
1523
1524 if a.gitRemoteAddr != "" {
1525 if headCommit == nil {
1526 // I think this can only happen if we have a bug or if there's a race.
1527 headCommit = &GitCommit{}
1528 headCommit.Hash = head
1529 headCommit.Subject = "unknown"
1530 commits = append(commits, headCommit)
1531 }
1532
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001533 branch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
Earl Lee2e463fb2025-04-17 11:22:22 -07001534
1535 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1536 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1537 // then use push with lease to replace.
1538 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1539 cmd.Dir = a.workingDir
1540 if out, err := cmd.CombinedOutput(); err != nil {
1541 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1542 } else {
1543 headCommit.PushedBranch = branch
1544 }
1545 }
1546
1547 // If we found new commits, create a message
1548 if len(commits) > 0 {
1549 msg := AgentMessage{
1550 Type: CommitMessageType,
1551 Timestamp: time.Now(),
1552 Commits: commits,
1553 }
1554 a.pushToOutbox(ctx, msg)
1555 }
1556 return commits, nil
1557}
1558
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001559func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001560 return strings.Map(func(r rune) rune {
1561 // lowercase
1562 if r >= 'A' && r <= 'Z' {
1563 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001564 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001565 // replace spaces with dashes
1566 if r == ' ' {
1567 return '-'
1568 }
1569 // allow alphanumerics and dashes
1570 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1571 return r
1572 }
1573 return -1
1574 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001575}
1576
1577// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1578// and returns an array of GitCommit structs.
1579func parseGitLog(output string) []GitCommit {
1580 var commits []GitCommit
1581
1582 // No output means no commits
1583 if len(output) == 0 {
1584 return commits
1585 }
1586
1587 // Split by NULL byte
1588 parts := strings.Split(output, "\x00")
1589
1590 // Process in triplets (hash, subject, body)
1591 for i := 0; i < len(parts); i++ {
1592 // Skip empty parts
1593 if parts[i] == "" {
1594 continue
1595 }
1596
1597 // This should be a hash
1598 hash := strings.TrimSpace(parts[i])
1599
1600 // Make sure we have at least a subject part available
1601 if i+1 >= len(parts) {
1602 break // No more parts available
1603 }
1604
1605 // Get the subject
1606 subject := strings.TrimSpace(parts[i+1])
1607
1608 // Get the body if available
1609 body := ""
1610 if i+2 < len(parts) {
1611 body = strings.TrimSpace(parts[i+2])
1612 }
1613
1614 // Skip to the next triplet
1615 i += 2
1616
1617 commits = append(commits, GitCommit{
1618 Hash: hash,
1619 Subject: subject,
1620 Body: body,
1621 })
1622 }
1623
1624 return commits
1625}
1626
1627func repoRoot(ctx context.Context, dir string) (string, error) {
1628 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1629 stderr := new(strings.Builder)
1630 cmd.Stderr = stderr
1631 cmd.Dir = dir
1632 out, err := cmd.Output()
1633 if err != nil {
1634 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1635 }
1636 return strings.TrimSpace(string(out)), nil
1637}
1638
1639func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1640 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1641 stderr := new(strings.Builder)
1642 cmd.Stderr = stderr
1643 cmd.Dir = dir
1644 out, err := cmd.Output()
1645 if err != nil {
1646 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1647 }
1648 // TODO: validate that out is valid hex
1649 return strings.TrimSpace(string(out)), nil
1650}
1651
1652// isValidGitSHA validates if a string looks like a valid git SHA hash.
1653// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1654func isValidGitSHA(sha string) bool {
1655 // Git SHA must be a hexadecimal string with at least 4 characters
1656 if len(sha) < 4 || len(sha) > 40 {
1657 return false
1658 }
1659
1660 // Check if the string only contains hexadecimal characters
1661 for _, char := range sha {
1662 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1663 return false
1664 }
1665 }
1666
1667 return true
1668}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001669
1670// getGitOrigin returns the URL of the git remote 'origin' if it exists
1671func getGitOrigin(ctx context.Context, dir string) string {
1672 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1673 cmd.Dir = dir
1674 stderr := new(strings.Builder)
1675 cmd.Stderr = stderr
1676 out, err := cmd.Output()
1677 if err != nil {
1678 return ""
1679 }
1680 return strings.TrimSpace(string(out))
1681}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001682
1683func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1684 cmd := exec.CommandContext(ctx, "git", "stash")
1685 cmd.Dir = workingDir
1686 if out, err := cmd.CombinedOutput(); err != nil {
1687 return fmt.Errorf("git stash: %s: %v", out, err)
1688 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001689 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001690 cmd.Dir = workingDir
1691 if out, err := cmd.CombinedOutput(); err != nil {
1692 return fmt.Errorf("git fetch: %s: %w", out, err)
1693 }
1694 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1695 cmd.Dir = workingDir
1696 if out, err := cmd.CombinedOutput(); err != nil {
1697 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1698 }
1699 a.lastHEAD = revision
1700 a.initialCommit = revision
1701 return nil
1702}
1703
1704func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1705 a.mu.Lock()
1706 a.title = ""
1707 a.firstMessageIndex = len(a.history)
1708 a.convo = a.initConvo()
1709 gitReset := func() error {
1710 if a.config.InDocker && rev != "" {
1711 err := a.initGitRevision(ctx, a.workingDir, rev)
1712 if err != nil {
1713 return err
1714 }
1715 } else if !a.config.InDocker && rev != "" {
1716 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1717 }
1718 return nil
1719 }
1720 err := gitReset()
1721 a.mu.Unlock()
1722 if err != nil {
1723 a.pushToOutbox(a.config.Context, errorMessage(err))
1724 }
1725
1726 a.pushToOutbox(a.config.Context, AgentMessage{
1727 Type: AgentMessageType, Content: "Conversation restarted.",
1728 })
1729 if initialPrompt != "" {
1730 a.UserMessage(ctx, initialPrompt)
1731 }
1732 return nil
1733}
1734
1735func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1736 msg := `The user has requested a suggestion for a re-prompt.
1737
1738 Given the current conversation thus far, suggest a re-prompt that would
1739 capture the instructions and feedback so far, as well as any
1740 research or other information that would be helpful in implementing
1741 the task.
1742
1743 Reply with ONLY the reprompt text.
1744 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001745 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001746 // By doing this in a subconversation, the agent doesn't call tools (because
1747 // there aren't any), and there's not a concurrency risk with on-going other
1748 // outstanding conversations.
1749 convo := a.convo.SubConvoWithHistory()
1750 resp, err := convo.SendMessage(userMessage)
1751 if err != nil {
1752 a.pushToOutbox(ctx, errorMessage(err))
1753 return "", err
1754 }
1755 textContent := collectTextContent(resp)
1756 return textContent, nil
1757}
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001758
1759// systemPromptData contains the data used to render the system prompt template
1760type systemPromptData struct {
1761 EditPrompt string
1762 ClientGOOS string
1763 ClientGOARCH string
1764 WorkingDir string
1765 RepoRoot string
1766 InitialCommit string
1767}
1768
1769// renderSystemPrompt renders the system prompt template.
1770func (a *Agent) renderSystemPrompt() string {
1771 // Determine the appropriate edit prompt based on config
1772 var editPrompt string
1773 if a.config.UseAnthropicEdit {
1774 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."
1775 } else {
1776 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1777 }
1778
1779 data := systemPromptData{
1780 EditPrompt: editPrompt,
1781 ClientGOOS: a.config.ClientGOOS,
1782 ClientGOARCH: a.config.ClientGOARCH,
1783 WorkingDir: a.workingDir,
1784 RepoRoot: a.repoRoot,
1785 InitialCommit: a.initialCommit,
1786 }
1787
1788 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1789 if err != nil {
1790 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1791 }
1792 buf := new(strings.Builder)
1793 err = tmpl.Execute(buf, data)
1794 if err != nil {
1795 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1796 }
1797 return buf.String()
1798}