blob: 6ce408a31379cf36f60181bc10af582cd61b71f8 [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"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000024 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -070025 "sketch.dev/experiment"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070026 "sketch.dev/llm"
27 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070028)
29
30const (
31 userCancelMessage = "user requested agent to stop handling responses"
32)
33
Philip Zeyligerb7c58752025-05-01 10:10:17 -070034type MessageIterator interface {
35 // Next blocks until the next message is available. It may
36 // return nil if the underlying iterator context is done.
37 Next() *AgentMessage
38 Close()
39}
40
Earl Lee2e463fb2025-04-17 11:22:22 -070041type CodingAgent interface {
42 // Init initializes an agent inside a docker container.
43 Init(AgentInit) error
44
45 // Ready returns a channel closed after Init successfully called.
46 Ready() <-chan struct{}
47
48 // URL reports the HTTP URL of this agent.
49 URL() string
50
51 // UserMessage enqueues a message to the agent and returns immediately.
52 UserMessage(ctx context.Context, msg string)
53
Philip Zeyligerb7c58752025-05-01 10:10:17 -070054 // Returns an iterator that finishes when the context is done and
55 // starts with the given message index.
56 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070057
58 // Loop begins the agent loop returns only when ctx is cancelled.
59 Loop(ctx context.Context)
60
Sean McCulloughedc88dc2025-04-30 02:55:01 +000061 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070062
63 CancelToolUse(toolUseID string, cause error) error
64
65 // Returns a subset of the agent's message history.
66 Messages(start int, end int) []AgentMessage
67
68 // Returns the current number of messages in the history
69 MessageCount() int
70
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070071 TotalUsage() conversation.CumulativeUsage
72 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070073
Earl Lee2e463fb2025-04-17 11:22:22 -070074 WorkingDir() string
75
76 // Diff returns a unified diff of changes made since the agent was instantiated.
77 // If commit is non-nil, it shows the diff for just that specific commit.
78 Diff(commit *string) (string, error)
79
80 // InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
81 InitialCommit() string
82
83 // Title returns the current title of the conversation.
84 Title() string
85
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000086 // BranchName returns the git branch name for the conversation.
87 BranchName() string
88
Earl Lee2e463fb2025-04-17 11:22:22 -070089 // OS returns the operating system of the client.
90 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +000091
Philip Zeyligerc72fff52025-04-29 20:17:54 +000092 // SessionID returns the unique session identifier.
93 SessionID() string
94
Philip Zeyliger99a9a022025-04-27 15:15:25 +000095 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
96 OutstandingLLMCallCount() int
97
98 // OutstandingToolCalls returns the names of outstanding tool calls.
99 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000100 OutsideOS() string
101 OutsideHostname() string
102 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000103 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000104 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
105 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700106
107 // RestartConversation resets the conversation history
108 RestartConversation(ctx context.Context, rev string, initialPrompt string) error
109 // SuggestReprompt suggests a re-prompt based on the current conversation.
110 SuggestReprompt(ctx context.Context) (string, error)
111 // IsInContainer returns true if the agent is running in a container
112 IsInContainer() bool
113 // FirstMessageIndex returns the index of the first message in the current conversation
114 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700115
116 CurrentStateName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700117}
118
119type CodingAgentMessageType string
120
121const (
122 UserMessageType CodingAgentMessageType = "user"
123 AgentMessageType CodingAgentMessageType = "agent"
124 ErrorMessageType CodingAgentMessageType = "error"
125 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
126 ToolUseMessageType CodingAgentMessageType = "tool"
127 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
128 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
129
130 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
131)
132
133type AgentMessage struct {
134 Type CodingAgentMessageType `json:"type"`
135 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
136 EndOfTurn bool `json:"end_of_turn"`
137
138 Content string `json:"content"`
139 ToolName string `json:"tool_name,omitempty"`
140 ToolInput string `json:"input,omitempty"`
141 ToolResult string `json:"tool_result,omitempty"`
142 ToolError bool `json:"tool_error,omitempty"`
143 ToolCallId string `json:"tool_call_id,omitempty"`
144
145 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
146 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
147
Sean McCulloughd9f13372025-04-21 15:08:49 -0700148 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
149 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
150
Earl Lee2e463fb2025-04-17 11:22:22 -0700151 // Commits is a list of git commits for a commit message
152 Commits []*GitCommit `json:"commits,omitempty"`
153
154 Timestamp time.Time `json:"timestamp"`
155 ConversationID string `json:"conversation_id"`
156 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700157 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700158
159 // Message timing information
160 StartTime *time.Time `json:"start_time,omitempty"`
161 EndTime *time.Time `json:"end_time,omitempty"`
162 Elapsed *time.Duration `json:"elapsed,omitempty"`
163
164 // Turn duration - the time taken for a complete agent turn
165 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
166
167 Idx int `json:"idx"`
168}
169
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700170// SetConvo sets m.ConversationID and m.ParentConversationID based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700171func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700172 if convo == nil {
173 m.ConversationID = ""
174 m.ParentConversationID = nil
175 return
176 }
177 m.ConversationID = convo.ID
178 if convo.Parent != nil {
179 m.ParentConversationID = &convo.Parent.ID
180 }
181}
182
Earl Lee2e463fb2025-04-17 11:22:22 -0700183// GitCommit represents a single git commit for a commit message
184type GitCommit struct {
185 Hash string `json:"hash"` // Full commit hash
186 Subject string `json:"subject"` // Commit subject line
187 Body string `json:"body"` // Full commit message body
188 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
189}
190
191// ToolCall represents a single tool call within an agent message
192type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700193 Name string `json:"name"`
194 Input string `json:"input"`
195 ToolCallId string `json:"tool_call_id"`
196 ResultMessage *AgentMessage `json:"result_message,omitempty"`
197 Args string `json:"args,omitempty"`
198 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700199}
200
201func (a *AgentMessage) Attr() slog.Attr {
202 var attrs []any = []any{
203 slog.String("type", string(a.Type)),
204 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700205 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700206 if a.EndOfTurn {
207 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
208 }
209 if a.Content != "" {
210 attrs = append(attrs, slog.String("content", a.Content))
211 }
212 if a.ToolName != "" {
213 attrs = append(attrs, slog.String("tool_name", a.ToolName))
214 }
215 if a.ToolInput != "" {
216 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
217 }
218 if a.Elapsed != nil {
219 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
220 }
221 if a.TurnDuration != nil {
222 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
223 }
224 if a.ToolResult != "" {
225 attrs = append(attrs, slog.String("tool_result", a.ToolResult))
226 }
227 if a.ToolError {
228 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
229 }
230 if len(a.ToolCalls) > 0 {
231 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
232 for i, tc := range a.ToolCalls {
233 toolCallAttrs = append(toolCallAttrs, slog.Group(
234 fmt.Sprintf("tool_call_%d", i),
235 slog.String("name", tc.Name),
236 slog.String("input", tc.Input),
237 ))
238 }
239 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
240 }
241 if a.ConversationID != "" {
242 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
243 }
244 if a.ParentConversationID != nil {
245 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
246 }
247 if a.Usage != nil && !a.Usage.IsZero() {
248 attrs = append(attrs, a.Usage.Attr())
249 }
250 // TODO: timestamp, convo ids, idx?
251 return slog.Group("agent_message", attrs...)
252}
253
254func errorMessage(err error) AgentMessage {
255 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
256 if os.Getenv(("DEBUG")) == "1" {
257 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
258 }
259
260 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
261}
262
263func budgetMessage(err error) AgentMessage {
264 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
265}
266
267// ConvoInterface defines the interface for conversation interactions
268type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700269 CumulativeUsage() conversation.CumulativeUsage
270 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700271 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700272 SendMessage(message llm.Message) (*llm.Response, error)
273 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700274 GetID() string
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700275 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error)
276 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700277 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700278 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700279}
280
281type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700282 convo ConvoInterface
283 config AgentConfig // config for this agent
284 workingDir string
285 repoRoot string // workingDir may be a subdir of repoRoot
286 url string
287 firstMessageIndex int // index of the first message in the current conversation
288 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
289 initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
290 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000291 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700292 ready chan struct{} // closed when the agent is initialized (only when under docker)
293 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700294 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700295 title string
296 branchName string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000297 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700298 // State machine to track agent state
299 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000300 // Outside information
301 outsideHostname string
302 outsideOS string
303 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000304 // URL of the git remote 'origin' if it exists
305 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700306
307 // Time when the current turn started (reset at the beginning of InnerLoop)
308 startOfTurn time.Time
309
310 // Inbox - for messages from the user to the agent.
311 // sent on by UserMessage
312 // . e.g. when user types into the chat textarea
313 // read from by GatherMessages
314 inbox chan string
315
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000316 // protects cancelTurn
317 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700318 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000319 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700320
321 // protects following
322 mu sync.Mutex
323
324 // Stores all messages for this agent
325 history []AgentMessage
326
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700327 // Iterators add themselves here when they're ready to be notified of new messages.
328 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700329
330 // Track git commits we've already seen (by hash)
331 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000332
333 // Track outstanding LLM call IDs
334 outstandingLLMCalls map[string]struct{}
335
336 // Track outstanding tool calls by ID with their names
337 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700338}
339
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700340// NewIterator implements CodingAgent.
341func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
342 a.mu.Lock()
343 defer a.mu.Unlock()
344
345 return &MessageIteratorImpl{
346 agent: a,
347 ctx: ctx,
348 nextMessageIdx: nextMessageIdx,
349 ch: make(chan *AgentMessage, 100),
350 }
351}
352
353type MessageIteratorImpl struct {
354 agent *Agent
355 ctx context.Context
356 nextMessageIdx int
357 ch chan *AgentMessage
358 subscribed bool
359}
360
361func (m *MessageIteratorImpl) Close() {
362 m.agent.mu.Lock()
363 defer m.agent.mu.Unlock()
364 // Delete ourselves from the subscribers list
365 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
366 return x == m.ch
367 })
368 close(m.ch)
369}
370
371func (m *MessageIteratorImpl) Next() *AgentMessage {
372 // We avoid subscription at creation to let ourselves catch up to "current state"
373 // before subscribing.
374 if !m.subscribed {
375 m.agent.mu.Lock()
376 if m.nextMessageIdx < len(m.agent.history) {
377 msg := &m.agent.history[m.nextMessageIdx]
378 m.nextMessageIdx++
379 m.agent.mu.Unlock()
380 return msg
381 }
382 // The next message doesn't exist yet, so let's subscribe
383 m.agent.subscribers = append(m.agent.subscribers, m.ch)
384 m.subscribed = true
385 m.agent.mu.Unlock()
386 }
387
388 for {
389 select {
390 case <-m.ctx.Done():
391 m.agent.mu.Lock()
392 // Delete ourselves from the subscribers list
393 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
394 return x == m.ch
395 })
396 m.subscribed = false
397 m.agent.mu.Unlock()
398 return nil
399 case msg, ok := <-m.ch:
400 if !ok {
401 // Close may have been called
402 return nil
403 }
404 if msg.Idx == m.nextMessageIdx {
405 m.nextMessageIdx++
406 return msg
407 }
408 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
409 panic("out of order message")
410 }
411 }
412}
413
Sean McCulloughd9d45812025-04-30 16:53:41 -0700414// Assert that Agent satisfies the CodingAgent interface.
415var _ CodingAgent = &Agent{}
416
417// StateName implements CodingAgent.
418func (a *Agent) CurrentStateName() string {
419 if a.stateMachine == nil {
420 return ""
421 }
422 return a.stateMachine.currentState.String()
423}
424
Earl Lee2e463fb2025-04-17 11:22:22 -0700425func (a *Agent) URL() string { return a.url }
426
427// Title returns the current title of the conversation.
428// If no title has been set, returns an empty string.
429func (a *Agent) Title() string {
430 a.mu.Lock()
431 defer a.mu.Unlock()
432 return a.title
433}
434
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000435// BranchName returns the git branch name for the conversation.
436func (a *Agent) BranchName() string {
437 a.mu.Lock()
438 defer a.mu.Unlock()
439 return a.branchName
440}
441
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000442// OutstandingLLMCallCount returns the number of outstanding LLM calls.
443func (a *Agent) OutstandingLLMCallCount() int {
444 a.mu.Lock()
445 defer a.mu.Unlock()
446 return len(a.outstandingLLMCalls)
447}
448
449// OutstandingToolCalls returns the names of outstanding tool calls.
450func (a *Agent) OutstandingToolCalls() []string {
451 a.mu.Lock()
452 defer a.mu.Unlock()
453
454 tools := make([]string, 0, len(a.outstandingToolCalls))
455 for _, toolName := range a.outstandingToolCalls {
456 tools = append(tools, toolName)
457 }
458 return tools
459}
460
Earl Lee2e463fb2025-04-17 11:22:22 -0700461// OS returns the operating system of the client.
462func (a *Agent) OS() string {
463 return a.config.ClientGOOS
464}
465
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000466func (a *Agent) SessionID() string {
467 return a.config.SessionID
468}
469
Philip Zeyliger18532b22025-04-23 21:11:46 +0000470// OutsideOS returns the operating system of the outside system.
471func (a *Agent) OutsideOS() string {
472 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000473}
474
Philip Zeyliger18532b22025-04-23 21:11:46 +0000475// OutsideHostname returns the hostname of the outside system.
476func (a *Agent) OutsideHostname() string {
477 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000478}
479
Philip Zeyliger18532b22025-04-23 21:11:46 +0000480// OutsideWorkingDir returns the working directory on the outside system.
481func (a *Agent) OutsideWorkingDir() string {
482 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000483}
484
485// GitOrigin returns the URL of the git remote 'origin' if it exists.
486func (a *Agent) GitOrigin() string {
487 return a.gitOrigin
488}
489
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000490func (a *Agent) OpenBrowser(url string) {
491 if !a.IsInContainer() {
492 browser.Open(url)
493 return
494 }
495 // We're in Docker, need to send a request to the Git server
496 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700497 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000498 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700499 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000500 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700501 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000502 return
503 }
504 defer resp.Body.Close()
505 if resp.StatusCode == http.StatusOK {
506 return
507 }
508 body, _ := io.ReadAll(resp.Body)
509 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
510}
511
Sean McCullough96b60dd2025-04-30 09:49:10 -0700512// CurrentState returns the current state of the agent's state machine.
513func (a *Agent) CurrentState() State {
514 return a.stateMachine.CurrentState()
515}
516
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700517func (a *Agent) IsInContainer() bool {
518 return a.config.InDocker
519}
520
521func (a *Agent) FirstMessageIndex() int {
522 a.mu.Lock()
523 defer a.mu.Unlock()
524 return a.firstMessageIndex
525}
526
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700527// SetTitleBranch sets the title and branch name of the conversation.
528func (a *Agent) SetTitleBranch(title, branchName string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700529 a.mu.Lock()
530 defer a.mu.Unlock()
531 a.title = title
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700532 a.branchName = branchName
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700533
534 // TODO: We could potentially notify listeners of a state change, but,
535 // realistically, a new message will be sent for the tool result as well.
Earl Lee2e463fb2025-04-17 11:22:22 -0700536}
537
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000538// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700539func (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 +0000540 // Track the tool call
541 a.mu.Lock()
542 a.outstandingToolCalls[id] = toolName
543 a.mu.Unlock()
544}
545
Earl Lee2e463fb2025-04-17 11:22:22 -0700546// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700547func (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 +0000548 // Remove the tool call from outstanding calls
549 a.mu.Lock()
550 delete(a.outstandingToolCalls, toolID)
551 a.mu.Unlock()
552
Earl Lee2e463fb2025-04-17 11:22:22 -0700553 m := AgentMessage{
554 Type: ToolUseMessageType,
555 Content: content.Text,
556 ToolResult: content.ToolResult,
557 ToolError: content.ToolError,
558 ToolName: toolName,
559 ToolInput: string(toolInput),
560 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700561 StartTime: content.ToolUseStartTime,
562 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700563 }
564
565 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700566 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
567 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700568 m.Elapsed = &elapsed
569 }
570
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700571 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700572 a.pushToOutbox(ctx, m)
573}
574
575// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700576func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000577 a.mu.Lock()
578 defer a.mu.Unlock()
579 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700580 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
581}
582
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700583// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700584// that need to be displayed (as well as tool calls that we send along when
585// they're done). (It would be reasonable to also mention tool calls when they're
586// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700587func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000588 // Remove the LLM call from outstanding calls
589 a.mu.Lock()
590 delete(a.outstandingLLMCalls, id)
591 a.mu.Unlock()
592
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700593 if resp == nil {
594 // LLM API call failed
595 m := AgentMessage{
596 Type: ErrorMessageType,
597 Content: "API call failed, type 'continue' to try again",
598 }
599 m.SetConvo(convo)
600 a.pushToOutbox(ctx, m)
601 return
602 }
603
Earl Lee2e463fb2025-04-17 11:22:22 -0700604 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700605 if convo.Parent == nil { // subconvos never end the turn
606 switch resp.StopReason {
607 case llm.StopReasonToolUse:
608 // Check whether any of the tool calls are for tools that should end the turn
609 ToolSearch:
610 for _, part := range resp.Content {
611 if part.Type != llm.ContentTypeToolUse {
612 continue
613 }
Sean McCullough021557a2025-05-05 23:20:53 +0000614 // Find the tool by name
615 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700616 if tool.Name == part.ToolName {
617 endOfTurn = tool.EndsTurn
618 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000619 }
620 }
Sean McCullough021557a2025-05-05 23:20:53 +0000621 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700622 default:
623 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000624 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700625 }
626 m := AgentMessage{
627 Type: AgentMessageType,
628 Content: collectTextContent(resp),
629 EndOfTurn: endOfTurn,
630 Usage: &resp.Usage,
631 StartTime: resp.StartTime,
632 EndTime: resp.EndTime,
633 }
634
635 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700636 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700637 var toolCalls []ToolCall
638 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700639 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700640 toolCalls = append(toolCalls, ToolCall{
641 Name: part.ToolName,
642 Input: string(part.ToolInput),
643 ToolCallId: part.ID,
644 })
645 }
646 }
647 m.ToolCalls = toolCalls
648 }
649
650 // Calculate the elapsed time if both start and end times are set
651 if resp.StartTime != nil && resp.EndTime != nil {
652 elapsed := resp.EndTime.Sub(*resp.StartTime)
653 m.Elapsed = &elapsed
654 }
655
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700656 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700657 a.pushToOutbox(ctx, m)
658}
659
660// WorkingDir implements CodingAgent.
661func (a *Agent) WorkingDir() string {
662 return a.workingDir
663}
664
665// MessageCount implements CodingAgent.
666func (a *Agent) MessageCount() int {
667 a.mu.Lock()
668 defer a.mu.Unlock()
669 return len(a.history)
670}
671
672// Messages implements CodingAgent.
673func (a *Agent) Messages(start int, end int) []AgentMessage {
674 a.mu.Lock()
675 defer a.mu.Unlock()
676 return slices.Clone(a.history[start:end])
677}
678
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700679func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700680 return a.originalBudget
681}
682
683// AgentConfig contains configuration for creating a new Agent.
684type AgentConfig struct {
685 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700686 Service llm.Service
687 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700688 GitUsername string
689 GitEmail string
690 SessionID string
691 ClientGOOS string
692 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700693 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700694 UseAnthropicEdit bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000695 // Outside information
696 OutsideHostname string
697 OutsideOS string
698 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700699}
700
701// NewAgent creates a new Agent.
702// It is not usable until Init() is called.
703func NewAgent(config AgentConfig) *Agent {
704 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000705 config: config,
706 ready: make(chan struct{}),
707 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700708 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000709 startedAt: time.Now(),
710 originalBudget: config.Budget,
711 seenCommits: make(map[string]bool),
712 outsideHostname: config.OutsideHostname,
713 outsideOS: config.OutsideOS,
714 outsideWorkingDir: config.OutsideWorkingDir,
715 outstandingLLMCalls: make(map[string]struct{}),
716 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700717 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700718 }
719 return agent
720}
721
722type AgentInit struct {
723 WorkingDir string
724 NoGit bool // only for testing
725
726 InDocker bool
727 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000728 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700729 GitRemoteAddr string
730 HostAddr string
731}
732
733func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700734 if a.convo != nil {
735 return fmt.Errorf("Agent.Init: already initialized")
736 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700737 ctx := a.config.Context
738 if ini.InDocker {
739 cmd := exec.CommandContext(ctx, "git", "stash")
740 cmd.Dir = ini.WorkingDir
741 if out, err := cmd.CombinedOutput(); err != nil {
742 return fmt.Errorf("git stash: %s: %v", out, err)
743 }
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700744 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
745 cmd.Dir = ini.WorkingDir
746 if out, err := cmd.CombinedOutput(); err != nil {
747 return fmt.Errorf("git remote add: %s: %v", out, err)
748 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000749 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700750 cmd.Dir = ini.WorkingDir
751 if out, err := cmd.CombinedOutput(); err != nil {
752 return fmt.Errorf("git fetch: %s: %w", out, err)
753 }
754 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
755 cmd.Dir = ini.WorkingDir
756 if out, err := cmd.CombinedOutput(); err != nil {
757 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
758 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700759 a.lastHEAD = ini.Commit
760 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000761 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700762 a.initialCommit = ini.Commit
763 if ini.HostAddr != "" {
764 a.url = "http://" + ini.HostAddr
765 }
766 }
767 a.workingDir = ini.WorkingDir
768
769 if !ini.NoGit {
770 repoRoot, err := repoRoot(ctx, a.workingDir)
771 if err != nil {
772 return fmt.Errorf("repoRoot: %w", err)
773 }
774 a.repoRoot = repoRoot
775
776 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
777 if err != nil {
778 return fmt.Errorf("resolveRef: %w", err)
779 }
780 a.initialCommit = commitHash
781
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000782 llmCodeReview := codereview.NoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700783 if experiment.Enabled("llm_review") {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000784 llmCodeReview = codereview.DoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700785 }
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000786 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit, llmCodeReview)
Earl Lee2e463fb2025-04-17 11:22:22 -0700787 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000788 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700789 }
790 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000791
792 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700793 }
794 a.lastHEAD = a.initialCommit
795 a.convo = a.initConvo()
796 close(a.ready)
797 return nil
798}
799
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700800//go:embed agent_system_prompt.txt
801var agentSystemPrompt string
802
Earl Lee2e463fb2025-04-17 11:22:22 -0700803// initConvo initializes the conversation.
804// It must not be called until all agent fields are initialized,
805// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700806func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700807 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700808 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700809 convo.PromptCaching = true
810 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000811 convo.SystemPrompt = a.renderSystemPrompt()
Earl Lee2e463fb2025-04-17 11:22:22 -0700812
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000813 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
814 bashPermissionCheck := func(command string) error {
815 // Check if branch name is set
816 a.mu.Lock()
817 branchSet := a.branchName != ""
818 a.mu.Unlock()
819
820 // If branch is set, all commands are allowed
821 if branchSet {
822 return nil
823 }
824
825 // If branch is not set, check if this is a git commit command
826 willCommit, err := bashkit.WillRunGitCommit(command)
827 if err != nil {
828 // If there's an error checking, we should allow the command to proceed
829 return nil
830 }
831
832 // If it's a git commit and branch is not set, return an error
833 if willCommit {
834 return fmt.Errorf("you must use the title tool before making git commits")
835 }
836
837 return nil
838 }
839
840 // Create a custom bash tool with the permission check
841 bashTool := claudetool.NewBashTool(bashPermissionCheck)
842
Earl Lee2e463fb2025-04-17 11:22:22 -0700843 // Register all tools with the conversation
844 // When adding, removing, or modifying tools here, double-check that the termui tool display
845 // template in termui/termui.go has pretty-printing support for all tools.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700846 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000847 bashTool, claudetool.Keyword,
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000848 claudetool.Think, a.preCommitTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
Sean McCullough485afc62025-04-28 14:28:39 -0700849 a.codereview.Tool(), a.multipleChoiceTool(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700850 }
851 if a.config.UseAnthropicEdit {
852 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
853 } else {
854 convo.Tools = append(convo.Tools, claudetool.Patch)
855 }
856 convo.Listener = a
857 return convo
858}
859
Sean McCullough485afc62025-04-28 14:28:39 -0700860func (a *Agent) multipleChoiceTool() *llm.Tool {
861 ret := &llm.Tool{
862 Name: "multiplechoice",
863 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 +0000864 EndsTurn: true,
Sean McCullough485afc62025-04-28 14:28:39 -0700865 InputSchema: json.RawMessage(`{
866 "type": "object",
867 "description": "The question and a list of answers you would expect the user to choose from.",
868 "properties": {
869 "question": {
870 "type": "string",
871 "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?'"
872 },
873 "responseOptions": {
874 "type": "array",
875 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
876 "items": {
877 "type": "object",
878 "properties": {
879 "caption": {
880 "type": "string",
881 "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'"
882 },
883 "responseText": {
884 "type": "string",
885 "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'"
886 }
887 },
888 "required": ["caption", "responseText"]
889 }
890 }
891 },
892 "required": ["question", "responseOptions"]
893}`),
894 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
895 // The Run logic for "multiplchoice" tool is a no-op on the server.
896 // The UI will present a list of options for the user to select from,
897 // and that's it as far as "executing" the tool_use goes.
898 // When the user *does* select one of the presented options, that
899 // responseText gets sent as a chat message on behalf of the user.
900 return "end your turn and wait for the user to respond", nil
901 },
902 }
903 return ret
904}
905
906type MultipleChoiceOption struct {
907 Caption string `json:"caption"`
908 ResponseText string `json:"responseText"`
909}
910
911type MultipleChoiceParams struct {
912 Question string `json:"question"`
913 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
914}
915
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000916// branchExists reports whether branchName exists, either locally or in well-known remotes.
917func branchExists(dir, branchName string) bool {
918 refs := []string{
919 "refs/heads/",
920 "refs/remotes/origin/",
921 "refs/remotes/sketch-host/",
922 }
923 for _, ref := range refs {
924 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
925 cmd.Dir = dir
926 if cmd.Run() == nil { // exit code 0 means branch exists
927 return true
928 }
929 }
930 return false
931}
932
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000933func (a *Agent) preCommitTool() *llm.Tool {
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000934 description := `Sets the conversation title and creates a git branch for tracking work. MANDATORY: You must use this tool before making any git commits.`
935 if experiment.Enabled("precommit") {
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000936 description = `Sets the conversation title, 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.`
937 }
938 preCommit := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -0700939 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000940 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -0700941 InputSchema: json.RawMessage(`{
942 "type": "object",
943 "properties": {
944 "title": {
945 "type": "string",
Josh Bleecher Snyder250348e2025-04-30 10:31:28 -0700946 "description": "A concise title summarizing what this conversation is about, imperative tense preferred"
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700947 },
948 "branch_name": {
949 "type": "string",
950 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
Earl Lee2e463fb2025-04-17 11:22:22 -0700951 }
952 },
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700953 "required": ["title", "branch_name"]
Earl Lee2e463fb2025-04-17 11:22:22 -0700954}`),
955 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
956 var params struct {
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700957 Title string `json:"title"`
958 BranchName string `json:"branch_name"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700959 }
960 if err := json.Unmarshal(input, &params); err != nil {
961 return "", err
962 }
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700963 // It's unfortunate to not allow title changes,
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000964 // but it avoids accidentally generating multiple branches.
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700965 t := a.Title()
966 if t != "" {
967 return "", fmt.Errorf("title already set to: %s", t)
968 }
969
970 if params.BranchName == "" {
971 return "", fmt.Errorf("branch_name parameter cannot be empty")
972 }
973 if params.Title == "" {
974 return "", fmt.Errorf("title parameter cannot be empty")
975 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -0700976 if params.BranchName != cleanBranchName(params.BranchName) {
977 return "", fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
978 }
979 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000980 if branchExists(a.workingDir, branchName) {
981 return "", fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
982 }
983
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700984 a.SetTitleBranch(params.Title, branchName)
985
986 response := fmt.Sprintf("Title set to %q, branch name set to %q", params.Title, branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000987
988 if experiment.Enabled("precommit") {
989 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
990 if err != nil {
991 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
992 }
993 if len(styleHint) > 0 {
994 response += "\n\n" + styleHint
995 }
996 }
997
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700998 return response, nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700999 },
1000 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001001 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001002}
1003
1004func (a *Agent) Ready() <-chan struct{} {
1005 return a.ready
1006}
1007
1008func (a *Agent) UserMessage(ctx context.Context, msg string) {
1009 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1010 a.inbox <- msg
1011}
1012
Sean McCullough485afc62025-04-28 14:28:39 -07001013func (a *Agent) ToolResultMessage(ctx context.Context, toolCallID, msg string) {
1014 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg, ToolCallId: toolCallID})
1015 a.inbox <- msg
1016}
1017
Earl Lee2e463fb2025-04-17 11:22:22 -07001018func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1019 return a.convo.CancelToolUse(toolUseID, cause)
1020}
1021
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001022func (a *Agent) CancelTurn(cause error) {
1023 a.cancelTurnMu.Lock()
1024 defer a.cancelTurnMu.Unlock()
1025 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001026 // Force state transition to cancelled state
1027 ctx := a.config.Context
1028 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001029 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001030 }
1031}
1032
1033func (a *Agent) Loop(ctxOuter context.Context) {
1034 for {
1035 select {
1036 case <-ctxOuter.Done():
1037 return
1038 default:
1039 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001040 a.cancelTurnMu.Lock()
1041 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001042 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001043 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001044 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001045 a.cancelTurn = cancel
1046 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001047 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1048 if err != nil {
1049 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1050 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001051 cancel(nil)
1052 }
1053 }
1054}
1055
1056func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1057 if m.Timestamp.IsZero() {
1058 m.Timestamp = time.Now()
1059 }
1060
1061 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1062 if m.EndOfTurn && m.Type == AgentMessageType {
1063 turnDuration := time.Since(a.startOfTurn)
1064 m.TurnDuration = &turnDuration
1065 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1066 }
1067
Earl Lee2e463fb2025-04-17 11:22:22 -07001068 a.mu.Lock()
1069 defer a.mu.Unlock()
1070 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001071 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001072 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001073
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001074 // Notify all subscribers
1075 for _, ch := range a.subscribers {
1076 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001077 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001078}
1079
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001080func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1081 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001082 if block {
1083 select {
1084 case <-ctx.Done():
1085 return m, ctx.Err()
1086 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001087 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001088 }
1089 }
1090 for {
1091 select {
1092 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001093 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001094 default:
1095 return m, nil
1096 }
1097 }
1098}
1099
Sean McCullough885a16a2025-04-30 02:49:25 +00001100// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001101func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001102 // Reset the start of turn time
1103 a.startOfTurn = time.Now()
1104
Sean McCullough96b60dd2025-04-30 09:49:10 -07001105 // Transition to waiting for user input state
1106 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1107
Sean McCullough885a16a2025-04-30 02:49:25 +00001108 // Process initial user message
1109 initialResp, err := a.processUserMessage(ctx)
1110 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001111 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001112 return err
1113 }
1114
1115 // Handle edge case where both initialResp and err are nil
1116 if initialResp == nil {
1117 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001118 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1119
Sean McCullough9f4b8082025-04-30 17:34:07 +00001120 a.pushToOutbox(ctx, errorMessage(err))
1121 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001122 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001123
Earl Lee2e463fb2025-04-17 11:22:22 -07001124 // We do this as we go, but let's also do it at the end of the turn
1125 defer func() {
1126 if _, err := a.handleGitCommits(ctx); err != nil {
1127 // Just log the error, don't stop execution
1128 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1129 }
1130 }()
1131
Sean McCullougha1e0e492025-05-01 10:51:08 -07001132 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001133 resp := initialResp
1134 for {
1135 // Check if we are over budget
1136 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001137 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001138 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001139 }
1140
1141 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001142 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001143 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001144 break
1145 }
1146
Sean McCullough96b60dd2025-04-30 09:49:10 -07001147 // Transition to tool use requested state
1148 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1149
Sean McCullough885a16a2025-04-30 02:49:25 +00001150 // Handle tool execution
1151 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1152 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001153 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001154 }
1155
Sean McCullougha1e0e492025-05-01 10:51:08 -07001156 if toolResp == nil {
1157 return fmt.Errorf("cannot continue conversation with a nil tool response")
1158 }
1159
Sean McCullough885a16a2025-04-30 02:49:25 +00001160 // Set the response for the next iteration
1161 resp = toolResp
1162 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001163
1164 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001165}
1166
1167// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001168func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001169 // Wait for at least one message from the user
1170 msgs, err := a.GatherMessages(ctx, true)
1171 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001172 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001173 return nil, err
1174 }
1175
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001176 userMessage := llm.Message{
1177 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001178 Content: msgs,
1179 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001180
Sean McCullough96b60dd2025-04-30 09:49:10 -07001181 // Transition to sending to LLM state
1182 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1183
Sean McCullough885a16a2025-04-30 02:49:25 +00001184 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001185 resp, err := a.convo.SendMessage(userMessage)
1186 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001187 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001188 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001189 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001190 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001191
Sean McCullough96b60dd2025-04-30 09:49:10 -07001192 // Transition to processing LLM response state
1193 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1194
Sean McCullough885a16a2025-04-30 02:49:25 +00001195 return resp, nil
1196}
1197
1198// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001199func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1200 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001201 cancelled := false
1202
Sean McCullough96b60dd2025-04-30 09:49:10 -07001203 // Transition to checking for cancellation state
1204 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1205
Sean McCullough885a16a2025-04-30 02:49:25 +00001206 // Check if the operation was cancelled by the user
1207 select {
1208 case <-ctx.Done():
1209 // Don't actually run any of the tools, but rather build a response
1210 // for each tool_use message letting the LLM know that user canceled it.
1211 var err error
1212 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001213 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001214 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001215 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001216 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001217 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001218 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001219 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001220 // Transition to running tool state
1221 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1222
Sean McCullough885a16a2025-04-30 02:49:25 +00001223 // Add working directory to context for tool execution
1224 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1225
1226 // Execute the tools
1227 var err error
1228 results, err = a.convo.ToolResultContents(ctx, resp)
1229 if ctx.Err() != nil { // e.g. the user canceled the operation
1230 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001231 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001232 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001233 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001234 a.pushToOutbox(ctx, errorMessage(err))
1235 }
1236 }
1237
1238 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001239 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001240 autoqualityMessages := a.processGitChanges(ctx)
1241
1242 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001243 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001244 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001245 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001246 return false, nil
1247 }
1248
1249 // Continue the conversation with tool results and any user messages
1250 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1251}
1252
1253// processGitChanges checks for new git commits and runs autoformatters if needed
1254func (a *Agent) processGitChanges(ctx context.Context) []string {
1255 // Check for git commits after tool execution
1256 newCommits, err := a.handleGitCommits(ctx)
1257 if err != nil {
1258 // Just log the error, don't stop execution
1259 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1260 return nil
1261 }
1262
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001263 // Run mechanical checks if there was exactly one new commit.
1264 if len(newCommits) != 1 {
1265 return nil
1266 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001267 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001268 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1269 msg := a.codereview.RunMechanicalChecks(ctx)
1270 if msg != "" {
1271 a.pushToOutbox(ctx, AgentMessage{
1272 Type: AutoMessageType,
1273 Content: msg,
1274 Timestamp: time.Now(),
1275 })
1276 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001277 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001278
1279 return autoqualityMessages
1280}
1281
1282// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001283func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001284 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001285 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001286 msgs, err := a.GatherMessages(ctx, false)
1287 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001288 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001289 return false, nil
1290 }
1291
1292 // Inject any auto-generated messages from quality checks
1293 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001294 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001295 }
1296
1297 // Handle cancellation by appending a message about it
1298 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001299 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001300 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001301 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001302 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1303 } else if err := a.convo.OverBudget(); err != nil {
1304 // Handle budget issues by appending a message about it
1305 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 -07001306 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001307 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1308 }
1309
1310 // Combine tool results with user messages
1311 results = append(results, msgs...)
1312
1313 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001314 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001315 resp, err := a.convo.SendMessage(llm.Message{
1316 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001317 Content: results,
1318 })
1319 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001320 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001321 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1322 return true, nil // Return true to continue the conversation, but with no response
1323 }
1324
Sean McCullough96b60dd2025-04-30 09:49:10 -07001325 // Transition back to processing LLM response
1326 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1327
Sean McCullough885a16a2025-04-30 02:49:25 +00001328 if cancelled {
1329 return false, nil
1330 }
1331
1332 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001333}
1334
1335func (a *Agent) overBudget(ctx context.Context) error {
1336 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001337 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001338 m := budgetMessage(err)
1339 m.Content = m.Content + "\n\nBudget reset."
1340 a.pushToOutbox(ctx, budgetMessage(err))
1341 a.convo.ResetBudget(a.originalBudget)
1342 return err
1343 }
1344 return nil
1345}
1346
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001347func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001348 // Collect all text content
1349 var allText strings.Builder
1350 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001351 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001352 if allText.Len() > 0 {
1353 allText.WriteString("\n\n")
1354 }
1355 allText.WriteString(content.Text)
1356 }
1357 }
1358 return allText.String()
1359}
1360
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001361func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001362 a.mu.Lock()
1363 defer a.mu.Unlock()
1364 return a.convo.CumulativeUsage()
1365}
1366
Earl Lee2e463fb2025-04-17 11:22:22 -07001367// Diff returns a unified diff of changes made since the agent was instantiated.
1368func (a *Agent) Diff(commit *string) (string, error) {
1369 if a.initialCommit == "" {
1370 return "", fmt.Errorf("no initial commit reference available")
1371 }
1372
1373 // Find the repository root
1374 ctx := context.Background()
1375
1376 // If a specific commit hash is provided, show just that commit's changes
1377 if commit != nil && *commit != "" {
1378 // Validate that the commit looks like a valid git SHA
1379 if !isValidGitSHA(*commit) {
1380 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1381 }
1382
1383 // Get the diff for just this commit
1384 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1385 cmd.Dir = a.repoRoot
1386 output, err := cmd.CombinedOutput()
1387 if err != nil {
1388 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1389 }
1390 return string(output), nil
1391 }
1392
1393 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1394 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1395 cmd.Dir = a.repoRoot
1396 output, err := cmd.CombinedOutput()
1397 if err != nil {
1398 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1399 }
1400
1401 return string(output), nil
1402}
1403
1404// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1405func (a *Agent) InitialCommit() string {
1406 return a.initialCommit
1407}
1408
1409// handleGitCommits() highlights new commits to the user. When running
1410// under docker, new HEADs are pushed to a branch according to the title.
1411func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1412 if a.repoRoot == "" {
1413 return nil, nil
1414 }
1415
1416 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1417 if err != nil {
1418 return nil, err
1419 }
1420 if head == a.lastHEAD {
1421 return nil, nil // nothing to do
1422 }
1423 defer func() {
1424 a.lastHEAD = head
1425 }()
1426
1427 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1428 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1429 // to the last 100 commits.
1430 var commits []*GitCommit
1431
1432 // Get commits since the initial commit
1433 // Format: <hash>\0<subject>\0<body>\0
1434 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1435 // Limit to 100 commits to avoid overwhelming the user
1436 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1437 cmd.Dir = a.repoRoot
1438 output, err := cmd.Output()
1439 if err != nil {
1440 return nil, fmt.Errorf("failed to get git log: %w", err)
1441 }
1442
1443 // Parse git log output and filter out already seen commits
1444 parsedCommits := parseGitLog(string(output))
1445
1446 var headCommit *GitCommit
1447
1448 // Filter out commits we've already seen
1449 for _, commit := range parsedCommits {
1450 if commit.Hash == head {
1451 headCommit = &commit
1452 }
1453
1454 // Skip if we've seen this commit before. If our head has changed, always include that.
1455 if a.seenCommits[commit.Hash] && commit.Hash != head {
1456 continue
1457 }
1458
1459 // Mark this commit as seen
1460 a.seenCommits[commit.Hash] = true
1461
1462 // Add to our list of new commits
1463 commits = append(commits, &commit)
1464 }
1465
1466 if a.gitRemoteAddr != "" {
1467 if headCommit == nil {
1468 // I think this can only happen if we have a bug or if there's a race.
1469 headCommit = &GitCommit{}
1470 headCommit.Hash = head
1471 headCommit.Subject = "unknown"
1472 commits = append(commits, headCommit)
1473 }
1474
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001475 branch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
Earl Lee2e463fb2025-04-17 11:22:22 -07001476
1477 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1478 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1479 // then use push with lease to replace.
1480 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1481 cmd.Dir = a.workingDir
1482 if out, err := cmd.CombinedOutput(); err != nil {
1483 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1484 } else {
1485 headCommit.PushedBranch = branch
1486 }
1487 }
1488
1489 // If we found new commits, create a message
1490 if len(commits) > 0 {
1491 msg := AgentMessage{
1492 Type: CommitMessageType,
1493 Timestamp: time.Now(),
1494 Commits: commits,
1495 }
1496 a.pushToOutbox(ctx, msg)
1497 }
1498 return commits, nil
1499}
1500
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001501func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001502 return strings.Map(func(r rune) rune {
1503 // lowercase
1504 if r >= 'A' && r <= 'Z' {
1505 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001506 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001507 // replace spaces with dashes
1508 if r == ' ' {
1509 return '-'
1510 }
1511 // allow alphanumerics and dashes
1512 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1513 return r
1514 }
1515 return -1
1516 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001517}
1518
1519// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1520// and returns an array of GitCommit structs.
1521func parseGitLog(output string) []GitCommit {
1522 var commits []GitCommit
1523
1524 // No output means no commits
1525 if len(output) == 0 {
1526 return commits
1527 }
1528
1529 // Split by NULL byte
1530 parts := strings.Split(output, "\x00")
1531
1532 // Process in triplets (hash, subject, body)
1533 for i := 0; i < len(parts); i++ {
1534 // Skip empty parts
1535 if parts[i] == "" {
1536 continue
1537 }
1538
1539 // This should be a hash
1540 hash := strings.TrimSpace(parts[i])
1541
1542 // Make sure we have at least a subject part available
1543 if i+1 >= len(parts) {
1544 break // No more parts available
1545 }
1546
1547 // Get the subject
1548 subject := strings.TrimSpace(parts[i+1])
1549
1550 // Get the body if available
1551 body := ""
1552 if i+2 < len(parts) {
1553 body = strings.TrimSpace(parts[i+2])
1554 }
1555
1556 // Skip to the next triplet
1557 i += 2
1558
1559 commits = append(commits, GitCommit{
1560 Hash: hash,
1561 Subject: subject,
1562 Body: body,
1563 })
1564 }
1565
1566 return commits
1567}
1568
1569func repoRoot(ctx context.Context, dir string) (string, error) {
1570 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1571 stderr := new(strings.Builder)
1572 cmd.Stderr = stderr
1573 cmd.Dir = dir
1574 out, err := cmd.Output()
1575 if err != nil {
1576 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1577 }
1578 return strings.TrimSpace(string(out)), nil
1579}
1580
1581func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1582 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1583 stderr := new(strings.Builder)
1584 cmd.Stderr = stderr
1585 cmd.Dir = dir
1586 out, err := cmd.Output()
1587 if err != nil {
1588 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1589 }
1590 // TODO: validate that out is valid hex
1591 return strings.TrimSpace(string(out)), nil
1592}
1593
1594// isValidGitSHA validates if a string looks like a valid git SHA hash.
1595// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1596func isValidGitSHA(sha string) bool {
1597 // Git SHA must be a hexadecimal string with at least 4 characters
1598 if len(sha) < 4 || len(sha) > 40 {
1599 return false
1600 }
1601
1602 // Check if the string only contains hexadecimal characters
1603 for _, char := range sha {
1604 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1605 return false
1606 }
1607 }
1608
1609 return true
1610}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001611
1612// getGitOrigin returns the URL of the git remote 'origin' if it exists
1613func getGitOrigin(ctx context.Context, dir string) string {
1614 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1615 cmd.Dir = dir
1616 stderr := new(strings.Builder)
1617 cmd.Stderr = stderr
1618 out, err := cmd.Output()
1619 if err != nil {
1620 return ""
1621 }
1622 return strings.TrimSpace(string(out))
1623}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001624
1625func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1626 cmd := exec.CommandContext(ctx, "git", "stash")
1627 cmd.Dir = workingDir
1628 if out, err := cmd.CombinedOutput(); err != nil {
1629 return fmt.Errorf("git stash: %s: %v", out, err)
1630 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001631 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001632 cmd.Dir = workingDir
1633 if out, err := cmd.CombinedOutput(); err != nil {
1634 return fmt.Errorf("git fetch: %s: %w", out, err)
1635 }
1636 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1637 cmd.Dir = workingDir
1638 if out, err := cmd.CombinedOutput(); err != nil {
1639 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1640 }
1641 a.lastHEAD = revision
1642 a.initialCommit = revision
1643 return nil
1644}
1645
1646func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1647 a.mu.Lock()
1648 a.title = ""
1649 a.firstMessageIndex = len(a.history)
1650 a.convo = a.initConvo()
1651 gitReset := func() error {
1652 if a.config.InDocker && rev != "" {
1653 err := a.initGitRevision(ctx, a.workingDir, rev)
1654 if err != nil {
1655 return err
1656 }
1657 } else if !a.config.InDocker && rev != "" {
1658 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1659 }
1660 return nil
1661 }
1662 err := gitReset()
1663 a.mu.Unlock()
1664 if err != nil {
1665 a.pushToOutbox(a.config.Context, errorMessage(err))
1666 }
1667
1668 a.pushToOutbox(a.config.Context, AgentMessage{
1669 Type: AgentMessageType, Content: "Conversation restarted.",
1670 })
1671 if initialPrompt != "" {
1672 a.UserMessage(ctx, initialPrompt)
1673 }
1674 return nil
1675}
1676
1677func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1678 msg := `The user has requested a suggestion for a re-prompt.
1679
1680 Given the current conversation thus far, suggest a re-prompt that would
1681 capture the instructions and feedback so far, as well as any
1682 research or other information that would be helpful in implementing
1683 the task.
1684
1685 Reply with ONLY the reprompt text.
1686 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001687 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001688 // By doing this in a subconversation, the agent doesn't call tools (because
1689 // there aren't any), and there's not a concurrency risk with on-going other
1690 // outstanding conversations.
1691 convo := a.convo.SubConvoWithHistory()
1692 resp, err := convo.SendMessage(userMessage)
1693 if err != nil {
1694 a.pushToOutbox(ctx, errorMessage(err))
1695 return "", err
1696 }
1697 textContent := collectTextContent(resp)
1698 return textContent, nil
1699}
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001700
1701// systemPromptData contains the data used to render the system prompt template
1702type systemPromptData struct {
1703 EditPrompt string
1704 ClientGOOS string
1705 ClientGOARCH string
1706 WorkingDir string
1707 RepoRoot string
1708 InitialCommit string
1709}
1710
1711// renderSystemPrompt renders the system prompt template.
1712func (a *Agent) renderSystemPrompt() string {
1713 // Determine the appropriate edit prompt based on config
1714 var editPrompt string
1715 if a.config.UseAnthropicEdit {
1716 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."
1717 } else {
1718 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1719 }
1720
1721 data := systemPromptData{
1722 EditPrompt: editPrompt,
1723 ClientGOOS: a.config.ClientGOOS,
1724 ClientGOARCH: a.config.ClientGOARCH,
1725 WorkingDir: a.workingDir,
1726 RepoRoot: a.repoRoot,
1727 InitialCommit: a.initialCommit,
1728 }
1729
1730 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1731 if err != nil {
1732 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1733 }
1734 buf := new(strings.Builder)
1735 err = tmpl.Execute(buf, data)
1736 if err != nil {
1737 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1738 }
1739 return buf.String()
1740}