blob: b5de370032b545bea0c9561cffeeb6ad9609435c [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
168 Idx int `json:"idx"`
169}
170
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700171// SetConvo sets m.ConversationID and m.ParentConversationID based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700172func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700173 if convo == nil {
174 m.ConversationID = ""
175 m.ParentConversationID = nil
176 return
177 }
178 m.ConversationID = convo.ID
179 if convo.Parent != nil {
180 m.ParentConversationID = &convo.Parent.ID
181 }
182}
183
Earl Lee2e463fb2025-04-17 11:22:22 -0700184// GitCommit represents a single git commit for a commit message
185type GitCommit struct {
186 Hash string `json:"hash"` // Full commit hash
187 Subject string `json:"subject"` // Commit subject line
188 Body string `json:"body"` // Full commit message body
189 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
190}
191
192// ToolCall represents a single tool call within an agent message
193type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700194 Name string `json:"name"`
195 Input string `json:"input"`
196 ToolCallId string `json:"tool_call_id"`
197 ResultMessage *AgentMessage `json:"result_message,omitempty"`
198 Args string `json:"args,omitempty"`
199 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700200}
201
202func (a *AgentMessage) Attr() slog.Attr {
203 var attrs []any = []any{
204 slog.String("type", string(a.Type)),
205 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700206 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700207 if a.EndOfTurn {
208 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
209 }
210 if a.Content != "" {
211 attrs = append(attrs, slog.String("content", a.Content))
212 }
213 if a.ToolName != "" {
214 attrs = append(attrs, slog.String("tool_name", a.ToolName))
215 }
216 if a.ToolInput != "" {
217 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
218 }
219 if a.Elapsed != nil {
220 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
221 }
222 if a.TurnDuration != nil {
223 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
224 }
225 if a.ToolResult != "" {
226 attrs = append(attrs, slog.String("tool_result", a.ToolResult))
227 }
228 if a.ToolError {
229 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
230 }
231 if len(a.ToolCalls) > 0 {
232 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
233 for i, tc := range a.ToolCalls {
234 toolCallAttrs = append(toolCallAttrs, slog.Group(
235 fmt.Sprintf("tool_call_%d", i),
236 slog.String("name", tc.Name),
237 slog.String("input", tc.Input),
238 ))
239 }
240 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
241 }
242 if a.ConversationID != "" {
243 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
244 }
245 if a.ParentConversationID != nil {
246 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
247 }
248 if a.Usage != nil && !a.Usage.IsZero() {
249 attrs = append(attrs, a.Usage.Attr())
250 }
251 // TODO: timestamp, convo ids, idx?
252 return slog.Group("agent_message", attrs...)
253}
254
255func errorMessage(err error) AgentMessage {
256 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
257 if os.Getenv(("DEBUG")) == "1" {
258 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
259 }
260
261 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
262}
263
264func budgetMessage(err error) AgentMessage {
265 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
266}
267
268// ConvoInterface defines the interface for conversation interactions
269type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700270 CumulativeUsage() conversation.CumulativeUsage
271 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700272 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700273 SendMessage(message llm.Message) (*llm.Response, error)
274 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700275 GetID() string
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700276 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error)
277 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700278 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700279 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700280}
281
282type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700283 convo ConvoInterface
284 config AgentConfig // config for this agent
285 workingDir string
286 repoRoot string // workingDir may be a subdir of repoRoot
287 url string
288 firstMessageIndex int // index of the first message in the current conversation
289 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
290 initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
291 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000292 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700293 ready chan struct{} // closed when the agent is initialized (only when under docker)
294 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700295 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700296 title string
297 branchName string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000298 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700299 // State machine to track agent state
300 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000301 // Outside information
302 outsideHostname string
303 outsideOS string
304 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000305 // URL of the git remote 'origin' if it exists
306 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700307
308 // Time when the current turn started (reset at the beginning of InnerLoop)
309 startOfTurn time.Time
310
311 // Inbox - for messages from the user to the agent.
312 // sent on by UserMessage
313 // . e.g. when user types into the chat textarea
314 // read from by GatherMessages
315 inbox chan string
316
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000317 // protects cancelTurn
318 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700319 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000320 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700321
322 // protects following
323 mu sync.Mutex
324
325 // Stores all messages for this agent
326 history []AgentMessage
327
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700328 // Iterators add themselves here when they're ready to be notified of new messages.
329 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700330
331 // Track git commits we've already seen (by hash)
332 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000333
334 // Track outstanding LLM call IDs
335 outstandingLLMCalls map[string]struct{}
336
337 // Track outstanding tool calls by ID with their names
338 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700339}
340
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700341// NewIterator implements CodingAgent.
342func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
343 a.mu.Lock()
344 defer a.mu.Unlock()
345
346 return &MessageIteratorImpl{
347 agent: a,
348 ctx: ctx,
349 nextMessageIdx: nextMessageIdx,
350 ch: make(chan *AgentMessage, 100),
351 }
352}
353
354type MessageIteratorImpl struct {
355 agent *Agent
356 ctx context.Context
357 nextMessageIdx int
358 ch chan *AgentMessage
359 subscribed bool
360}
361
362func (m *MessageIteratorImpl) Close() {
363 m.agent.mu.Lock()
364 defer m.agent.mu.Unlock()
365 // Delete ourselves from the subscribers list
366 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
367 return x == m.ch
368 })
369 close(m.ch)
370}
371
372func (m *MessageIteratorImpl) Next() *AgentMessage {
373 // We avoid subscription at creation to let ourselves catch up to "current state"
374 // before subscribing.
375 if !m.subscribed {
376 m.agent.mu.Lock()
377 if m.nextMessageIdx < len(m.agent.history) {
378 msg := &m.agent.history[m.nextMessageIdx]
379 m.nextMessageIdx++
380 m.agent.mu.Unlock()
381 return msg
382 }
383 // The next message doesn't exist yet, so let's subscribe
384 m.agent.subscribers = append(m.agent.subscribers, m.ch)
385 m.subscribed = true
386 m.agent.mu.Unlock()
387 }
388
389 for {
390 select {
391 case <-m.ctx.Done():
392 m.agent.mu.Lock()
393 // Delete ourselves from the subscribers list
394 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
395 return x == m.ch
396 })
397 m.subscribed = false
398 m.agent.mu.Unlock()
399 return nil
400 case msg, ok := <-m.ch:
401 if !ok {
402 // Close may have been called
403 return nil
404 }
405 if msg.Idx == m.nextMessageIdx {
406 m.nextMessageIdx++
407 return msg
408 }
409 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
410 panic("out of order message")
411 }
412 }
413}
414
Sean McCulloughd9d45812025-04-30 16:53:41 -0700415// Assert that Agent satisfies the CodingAgent interface.
416var _ CodingAgent = &Agent{}
417
418// StateName implements CodingAgent.
419func (a *Agent) CurrentStateName() string {
420 if a.stateMachine == nil {
421 return ""
422 }
423 return a.stateMachine.currentState.String()
424}
425
Earl Lee2e463fb2025-04-17 11:22:22 -0700426func (a *Agent) URL() string { return a.url }
427
428// Title returns the current title of the conversation.
429// If no title has been set, returns an empty string.
430func (a *Agent) Title() string {
431 a.mu.Lock()
432 defer a.mu.Unlock()
433 return a.title
434}
435
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000436// BranchName returns the git branch name for the conversation.
437func (a *Agent) BranchName() string {
438 a.mu.Lock()
439 defer a.mu.Unlock()
440 return a.branchName
441}
442
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000443// OutstandingLLMCallCount returns the number of outstanding LLM calls.
444func (a *Agent) OutstandingLLMCallCount() int {
445 a.mu.Lock()
446 defer a.mu.Unlock()
447 return len(a.outstandingLLMCalls)
448}
449
450// OutstandingToolCalls returns the names of outstanding tool calls.
451func (a *Agent) OutstandingToolCalls() []string {
452 a.mu.Lock()
453 defer a.mu.Unlock()
454
455 tools := make([]string, 0, len(a.outstandingToolCalls))
456 for _, toolName := range a.outstandingToolCalls {
457 tools = append(tools, toolName)
458 }
459 return tools
460}
461
Earl Lee2e463fb2025-04-17 11:22:22 -0700462// OS returns the operating system of the client.
463func (a *Agent) OS() string {
464 return a.config.ClientGOOS
465}
466
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000467func (a *Agent) SessionID() string {
468 return a.config.SessionID
469}
470
Philip Zeyliger18532b22025-04-23 21:11:46 +0000471// OutsideOS returns the operating system of the outside system.
472func (a *Agent) OutsideOS() string {
473 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000474}
475
Philip Zeyliger18532b22025-04-23 21:11:46 +0000476// OutsideHostname returns the hostname of the outside system.
477func (a *Agent) OutsideHostname() string {
478 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000479}
480
Philip Zeyliger18532b22025-04-23 21:11:46 +0000481// OutsideWorkingDir returns the working directory on the outside system.
482func (a *Agent) OutsideWorkingDir() string {
483 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000484}
485
486// GitOrigin returns the URL of the git remote 'origin' if it exists.
487func (a *Agent) GitOrigin() string {
488 return a.gitOrigin
489}
490
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000491func (a *Agent) OpenBrowser(url string) {
492 if !a.IsInContainer() {
493 browser.Open(url)
494 return
495 }
496 // We're in Docker, need to send a request to the Git server
497 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700498 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000499 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700500 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000501 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700502 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000503 return
504 }
505 defer resp.Body.Close()
506 if resp.StatusCode == http.StatusOK {
507 return
508 }
509 body, _ := io.ReadAll(resp.Body)
510 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
511}
512
Sean McCullough96b60dd2025-04-30 09:49:10 -0700513// CurrentState returns the current state of the agent's state machine.
514func (a *Agent) CurrentState() State {
515 return a.stateMachine.CurrentState()
516}
517
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700518func (a *Agent) IsInContainer() bool {
519 return a.config.InDocker
520}
521
522func (a *Agent) FirstMessageIndex() int {
523 a.mu.Lock()
524 defer a.mu.Unlock()
525 return a.firstMessageIndex
526}
527
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000528// SetTitle sets the title of the conversation.
529func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700530 a.mu.Lock()
531 defer a.mu.Unlock()
532 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000533}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700534
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000535// SetBranch sets the branch name of the conversation.
536func (a *Agent) SetBranch(branchName string) {
537 a.mu.Lock()
538 defer a.mu.Unlock()
539 a.branchName = branchName
Earl Lee2e463fb2025-04-17 11:22:22 -0700540}
541
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000542// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700543func (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 +0000544 // Track the tool call
545 a.mu.Lock()
546 a.outstandingToolCalls[id] = toolName
547 a.mu.Unlock()
548}
549
Earl Lee2e463fb2025-04-17 11:22:22 -0700550// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700551func (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 +0000552 // Remove the tool call from outstanding calls
553 a.mu.Lock()
554 delete(a.outstandingToolCalls, toolID)
555 a.mu.Unlock()
556
Earl Lee2e463fb2025-04-17 11:22:22 -0700557 m := AgentMessage{
558 Type: ToolUseMessageType,
559 Content: content.Text,
560 ToolResult: content.ToolResult,
561 ToolError: content.ToolError,
562 ToolName: toolName,
563 ToolInput: string(toolInput),
564 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700565 StartTime: content.ToolUseStartTime,
566 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700567 }
568
569 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700570 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
571 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700572 m.Elapsed = &elapsed
573 }
574
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700575 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700576 a.pushToOutbox(ctx, m)
577}
578
579// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700580func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000581 a.mu.Lock()
582 defer a.mu.Unlock()
583 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700584 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
585}
586
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700587// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700588// that need to be displayed (as well as tool calls that we send along when
589// they're done). (It would be reasonable to also mention tool calls when they're
590// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700591func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000592 // Remove the LLM call from outstanding calls
593 a.mu.Lock()
594 delete(a.outstandingLLMCalls, id)
595 a.mu.Unlock()
596
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700597 if resp == nil {
598 // LLM API call failed
599 m := AgentMessage{
600 Type: ErrorMessageType,
601 Content: "API call failed, type 'continue' to try again",
602 }
603 m.SetConvo(convo)
604 a.pushToOutbox(ctx, m)
605 return
606 }
607
Earl Lee2e463fb2025-04-17 11:22:22 -0700608 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700609 if convo.Parent == nil { // subconvos never end the turn
610 switch resp.StopReason {
611 case llm.StopReasonToolUse:
612 // Check whether any of the tool calls are for tools that should end the turn
613 ToolSearch:
614 for _, part := range resp.Content {
615 if part.Type != llm.ContentTypeToolUse {
616 continue
617 }
Sean McCullough021557a2025-05-05 23:20:53 +0000618 // Find the tool by name
619 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700620 if tool.Name == part.ToolName {
621 endOfTurn = tool.EndsTurn
622 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000623 }
624 }
Sean McCullough021557a2025-05-05 23:20:53 +0000625 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700626 default:
627 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000628 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700629 }
630 m := AgentMessage{
631 Type: AgentMessageType,
632 Content: collectTextContent(resp),
633 EndOfTurn: endOfTurn,
634 Usage: &resp.Usage,
635 StartTime: resp.StartTime,
636 EndTime: resp.EndTime,
637 }
638
639 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700640 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700641 var toolCalls []ToolCall
642 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700643 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700644 toolCalls = append(toolCalls, ToolCall{
645 Name: part.ToolName,
646 Input: string(part.ToolInput),
647 ToolCallId: part.ID,
648 })
649 }
650 }
651 m.ToolCalls = toolCalls
652 }
653
654 // Calculate the elapsed time if both start and end times are set
655 if resp.StartTime != nil && resp.EndTime != nil {
656 elapsed := resp.EndTime.Sub(*resp.StartTime)
657 m.Elapsed = &elapsed
658 }
659
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700660 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700661 a.pushToOutbox(ctx, m)
662}
663
664// WorkingDir implements CodingAgent.
665func (a *Agent) WorkingDir() string {
666 return a.workingDir
667}
668
669// MessageCount implements CodingAgent.
670func (a *Agent) MessageCount() int {
671 a.mu.Lock()
672 defer a.mu.Unlock()
673 return len(a.history)
674}
675
676// Messages implements CodingAgent.
677func (a *Agent) Messages(start int, end int) []AgentMessage {
678 a.mu.Lock()
679 defer a.mu.Unlock()
680 return slices.Clone(a.history[start:end])
681}
682
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700683func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700684 return a.originalBudget
685}
686
687// AgentConfig contains configuration for creating a new Agent.
688type AgentConfig struct {
689 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700690 Service llm.Service
691 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700692 GitUsername string
693 GitEmail string
694 SessionID string
695 ClientGOOS string
696 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700697 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700698 UseAnthropicEdit bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000699 // Outside information
700 OutsideHostname string
701 OutsideOS string
702 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700703}
704
705// NewAgent creates a new Agent.
706// It is not usable until Init() is called.
707func NewAgent(config AgentConfig) *Agent {
708 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000709 config: config,
710 ready: make(chan struct{}),
711 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700712 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000713 startedAt: time.Now(),
714 originalBudget: config.Budget,
715 seenCommits: make(map[string]bool),
716 outsideHostname: config.OutsideHostname,
717 outsideOS: config.OutsideOS,
718 outsideWorkingDir: config.OutsideWorkingDir,
719 outstandingLLMCalls: make(map[string]struct{}),
720 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700721 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700722 }
723 return agent
724}
725
726type AgentInit struct {
727 WorkingDir string
728 NoGit bool // only for testing
729
730 InDocker bool
731 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000732 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700733 GitRemoteAddr string
734 HostAddr string
735}
736
737func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700738 if a.convo != nil {
739 return fmt.Errorf("Agent.Init: already initialized")
740 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700741 ctx := a.config.Context
742 if ini.InDocker {
743 cmd := exec.CommandContext(ctx, "git", "stash")
744 cmd.Dir = ini.WorkingDir
745 if out, err := cmd.CombinedOutput(); err != nil {
746 return fmt.Errorf("git stash: %s: %v", out, err)
747 }
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700748 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
749 cmd.Dir = ini.WorkingDir
750 if out, err := cmd.CombinedOutput(); err != nil {
751 return fmt.Errorf("git remote add: %s: %v", out, err)
752 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000753 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700754 cmd.Dir = ini.WorkingDir
755 if out, err := cmd.CombinedOutput(); err != nil {
756 return fmt.Errorf("git fetch: %s: %w", out, err)
757 }
758 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
759 cmd.Dir = ini.WorkingDir
760 if out, err := cmd.CombinedOutput(); err != nil {
761 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
762 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700763 a.lastHEAD = ini.Commit
764 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000765 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700766 a.initialCommit = ini.Commit
767 if ini.HostAddr != "" {
768 a.url = "http://" + ini.HostAddr
769 }
770 }
771 a.workingDir = ini.WorkingDir
772
773 if !ini.NoGit {
774 repoRoot, err := repoRoot(ctx, a.workingDir)
775 if err != nil {
776 return fmt.Errorf("repoRoot: %w", err)
777 }
778 a.repoRoot = repoRoot
779
780 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
781 if err != nil {
782 return fmt.Errorf("resolveRef: %w", err)
783 }
784 a.initialCommit = commitHash
785
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000786 llmCodeReview := codereview.NoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700787 if experiment.Enabled("llm_review") {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000788 llmCodeReview = codereview.DoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700789 }
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000790 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit, llmCodeReview)
Earl Lee2e463fb2025-04-17 11:22:22 -0700791 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000792 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700793 }
794 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000795
796 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700797 }
798 a.lastHEAD = a.initialCommit
799 a.convo = a.initConvo()
800 close(a.ready)
801 return nil
802}
803
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700804//go:embed agent_system_prompt.txt
805var agentSystemPrompt string
806
Earl Lee2e463fb2025-04-17 11:22:22 -0700807// initConvo initializes the conversation.
808// It must not be called until all agent fields are initialized,
809// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700810func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700811 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700812 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700813 convo.PromptCaching = true
814 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000815 convo.SystemPrompt = a.renderSystemPrompt()
Earl Lee2e463fb2025-04-17 11:22:22 -0700816
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000817 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
818 bashPermissionCheck := func(command string) error {
819 // Check if branch name is set
820 a.mu.Lock()
821 branchSet := a.branchName != ""
822 a.mu.Unlock()
823
824 // If branch is set, all commands are allowed
825 if branchSet {
826 return nil
827 }
828
829 // If branch is not set, check if this is a git commit command
830 willCommit, err := bashkit.WillRunGitCommit(command)
831 if err != nil {
832 // If there's an error checking, we should allow the command to proceed
833 return nil
834 }
835
836 // If it's a git commit and branch is not set, return an error
837 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000838 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000839 }
840
841 return nil
842 }
843
844 // Create a custom bash tool with the permission check
845 bashTool := claudetool.NewBashTool(bashPermissionCheck)
846
Earl Lee2e463fb2025-04-17 11:22:22 -0700847 // Register all tools with the conversation
848 // When adding, removing, or modifying tools here, double-check that the termui tool display
849 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000850
851 var browserTools []*llm.Tool
852 // Add browser tools if enabled
853 // if experiment.Enabled("browser") {
854 if true {
855 bTools, browserCleanup := browse.RegisterBrowserTools(a.config.Context)
856 // Add cleanup function to context cancel
857 go func() {
858 <-a.config.Context.Done()
859 browserCleanup()
860 }()
861 browserTools = bTools
862 }
863
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700864 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000865 bashTool, claudetool.Keyword,
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000866 claudetool.Think, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
Sean McCullough485afc62025-04-28 14:28:39 -0700867 a.codereview.Tool(), a.multipleChoiceTool(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700868 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000869
870 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -0700871 if a.config.UseAnthropicEdit {
872 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
873 } else {
874 convo.Tools = append(convo.Tools, claudetool.Patch)
875 }
876 convo.Listener = a
877 return convo
878}
879
Sean McCullough485afc62025-04-28 14:28:39 -0700880func (a *Agent) multipleChoiceTool() *llm.Tool {
881 ret := &llm.Tool{
882 Name: "multiplechoice",
883 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 +0000884 EndsTurn: true,
Sean McCullough485afc62025-04-28 14:28:39 -0700885 InputSchema: json.RawMessage(`{
886 "type": "object",
887 "description": "The question and a list of answers you would expect the user to choose from.",
888 "properties": {
889 "question": {
890 "type": "string",
891 "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?'"
892 },
893 "responseOptions": {
894 "type": "array",
895 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
896 "items": {
897 "type": "object",
898 "properties": {
899 "caption": {
900 "type": "string",
901 "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'"
902 },
903 "responseText": {
904 "type": "string",
905 "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'"
906 }
907 },
908 "required": ["caption", "responseText"]
909 }
910 }
911 },
912 "required": ["question", "responseOptions"]
913}`),
914 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
915 // The Run logic for "multiplchoice" tool is a no-op on the server.
916 // The UI will present a list of options for the user to select from,
917 // and that's it as far as "executing" the tool_use goes.
918 // When the user *does* select one of the presented options, that
919 // responseText gets sent as a chat message on behalf of the user.
920 return "end your turn and wait for the user to respond", nil
921 },
922 }
923 return ret
924}
925
926type MultipleChoiceOption struct {
927 Caption string `json:"caption"`
928 ResponseText string `json:"responseText"`
929}
930
931type MultipleChoiceParams struct {
932 Question string `json:"question"`
933 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
934}
935
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000936// branchExists reports whether branchName exists, either locally or in well-known remotes.
937func branchExists(dir, branchName string) bool {
938 refs := []string{
939 "refs/heads/",
940 "refs/remotes/origin/",
941 "refs/remotes/sketch-host/",
942 }
943 for _, ref := range refs {
944 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
945 cmd.Dir = dir
946 if cmd.Run() == nil { // exit code 0 means branch exists
947 return true
948 }
949 }
950 return false
951}
952
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000953func (a *Agent) titleTool() *llm.Tool {
954 description := `Sets the conversation title.`
955 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -0700956 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000957 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -0700958 InputSchema: json.RawMessage(`{
959 "type": "object",
960 "properties": {
961 "title": {
962 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000963 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -0700964 }
965 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000966 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -0700967}`),
968 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
969 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000970 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700971 }
972 if err := json.Unmarshal(input, &params); err != nil {
973 return "", err
974 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000975
976 // We don't allow changing the title once set to be consistent with the previous behavior
977 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700978 t := a.Title()
979 if t != "" {
980 return "", fmt.Errorf("title already set to: %s", t)
981 }
982
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700983 if params.Title == "" {
984 return "", fmt.Errorf("title parameter cannot be empty")
985 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000986
987 a.SetTitle(params.Title)
988 response := fmt.Sprintf("Title set to %q", params.Title)
989 return response, nil
990 },
991 }
992 return titleTool
993}
994
995func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +0000996 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 +0000997 preCommit := &llm.Tool{
998 Name: "precommit",
999 Description: description,
1000 InputSchema: json.RawMessage(`{
1001 "type": "object",
1002 "properties": {
1003 "branch_name": {
1004 "type": "string",
1005 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1006 }
1007 },
1008 "required": ["branch_name"]
1009}`),
1010 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
1011 var params struct {
1012 BranchName string `json:"branch_name"`
1013 }
1014 if err := json.Unmarshal(input, &params); err != nil {
1015 return "", err
1016 }
1017
1018 b := a.BranchName()
1019 if b != "" {
1020 return "", fmt.Errorf("branch already set to: %s", b)
1021 }
1022
1023 if params.BranchName == "" {
1024 return "", fmt.Errorf("branch_name parameter cannot be empty")
1025 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001026 if params.BranchName != cleanBranchName(params.BranchName) {
1027 return "", fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
1028 }
1029 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001030 if branchExists(a.workingDir, branchName) {
1031 return "", fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
1032 }
1033
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001034 a.SetBranch(branchName)
1035 response := fmt.Sprintf("Branch name set to %q", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001036
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001037 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1038 if err != nil {
1039 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1040 }
1041 if len(styleHint) > 0 {
1042 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001043 }
1044
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001045 return response, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001046 },
1047 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001048 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001049}
1050
1051func (a *Agent) Ready() <-chan struct{} {
1052 return a.ready
1053}
1054
1055func (a *Agent) UserMessage(ctx context.Context, msg string) {
1056 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1057 a.inbox <- msg
1058}
1059
Sean McCullough485afc62025-04-28 14:28:39 -07001060func (a *Agent) ToolResultMessage(ctx context.Context, toolCallID, msg string) {
1061 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg, ToolCallId: toolCallID})
1062 a.inbox <- msg
1063}
1064
Earl Lee2e463fb2025-04-17 11:22:22 -07001065func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1066 return a.convo.CancelToolUse(toolUseID, cause)
1067}
1068
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001069func (a *Agent) CancelTurn(cause error) {
1070 a.cancelTurnMu.Lock()
1071 defer a.cancelTurnMu.Unlock()
1072 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001073 // Force state transition to cancelled state
1074 ctx := a.config.Context
1075 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001076 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001077 }
1078}
1079
1080func (a *Agent) Loop(ctxOuter context.Context) {
1081 for {
1082 select {
1083 case <-ctxOuter.Done():
1084 return
1085 default:
1086 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001087 a.cancelTurnMu.Lock()
1088 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001089 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001090 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001091 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001092 a.cancelTurn = cancel
1093 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001094 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1095 if err != nil {
1096 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1097 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001098 cancel(nil)
1099 }
1100 }
1101}
1102
1103func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1104 if m.Timestamp.IsZero() {
1105 m.Timestamp = time.Now()
1106 }
1107
1108 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1109 if m.EndOfTurn && m.Type == AgentMessageType {
1110 turnDuration := time.Since(a.startOfTurn)
1111 m.TurnDuration = &turnDuration
1112 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1113 }
1114
Earl Lee2e463fb2025-04-17 11:22:22 -07001115 a.mu.Lock()
1116 defer a.mu.Unlock()
1117 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001118 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001119 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001120
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001121 // Notify all subscribers
1122 for _, ch := range a.subscribers {
1123 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001124 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001125}
1126
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001127func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1128 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001129 if block {
1130 select {
1131 case <-ctx.Done():
1132 return m, ctx.Err()
1133 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001134 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001135 }
1136 }
1137 for {
1138 select {
1139 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001140 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001141 default:
1142 return m, nil
1143 }
1144 }
1145}
1146
Sean McCullough885a16a2025-04-30 02:49:25 +00001147// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001148func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001149 // Reset the start of turn time
1150 a.startOfTurn = time.Now()
1151
Sean McCullough96b60dd2025-04-30 09:49:10 -07001152 // Transition to waiting for user input state
1153 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1154
Sean McCullough885a16a2025-04-30 02:49:25 +00001155 // Process initial user message
1156 initialResp, err := a.processUserMessage(ctx)
1157 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001158 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001159 return err
1160 }
1161
1162 // Handle edge case where both initialResp and err are nil
1163 if initialResp == nil {
1164 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001165 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1166
Sean McCullough9f4b8082025-04-30 17:34:07 +00001167 a.pushToOutbox(ctx, errorMessage(err))
1168 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001169 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001170
Earl Lee2e463fb2025-04-17 11:22:22 -07001171 // We do this as we go, but let's also do it at the end of the turn
1172 defer func() {
1173 if _, err := a.handleGitCommits(ctx); err != nil {
1174 // Just log the error, don't stop execution
1175 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1176 }
1177 }()
1178
Sean McCullougha1e0e492025-05-01 10:51:08 -07001179 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001180 resp := initialResp
1181 for {
1182 // Check if we are over budget
1183 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001184 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001185 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001186 }
1187
1188 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001189 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001190 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001191 break
1192 }
1193
Sean McCullough96b60dd2025-04-30 09:49:10 -07001194 // Transition to tool use requested state
1195 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1196
Sean McCullough885a16a2025-04-30 02:49:25 +00001197 // Handle tool execution
1198 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1199 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001200 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001201 }
1202
Sean McCullougha1e0e492025-05-01 10:51:08 -07001203 if toolResp == nil {
1204 return fmt.Errorf("cannot continue conversation with a nil tool response")
1205 }
1206
Sean McCullough885a16a2025-04-30 02:49:25 +00001207 // Set the response for the next iteration
1208 resp = toolResp
1209 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001210
1211 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001212}
1213
1214// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001215func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001216 // Wait for at least one message from the user
1217 msgs, err := a.GatherMessages(ctx, true)
1218 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001219 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001220 return nil, err
1221 }
1222
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001223 userMessage := llm.Message{
1224 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001225 Content: msgs,
1226 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001227
Sean McCullough96b60dd2025-04-30 09:49:10 -07001228 // Transition to sending to LLM state
1229 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1230
Sean McCullough885a16a2025-04-30 02:49:25 +00001231 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001232 resp, err := a.convo.SendMessage(userMessage)
1233 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001234 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001235 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001236 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001237 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001238
Sean McCullough96b60dd2025-04-30 09:49:10 -07001239 // Transition to processing LLM response state
1240 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1241
Sean McCullough885a16a2025-04-30 02:49:25 +00001242 return resp, nil
1243}
1244
1245// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001246func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1247 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001248 cancelled := false
1249
Sean McCullough96b60dd2025-04-30 09:49:10 -07001250 // Transition to checking for cancellation state
1251 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1252
Sean McCullough885a16a2025-04-30 02:49:25 +00001253 // Check if the operation was cancelled by the user
1254 select {
1255 case <-ctx.Done():
1256 // Don't actually run any of the tools, but rather build a response
1257 // for each tool_use message letting the LLM know that user canceled it.
1258 var err error
1259 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001260 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001261 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001262 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001263 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001264 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001265 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001266 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001267 // Transition to running tool state
1268 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1269
Sean McCullough885a16a2025-04-30 02:49:25 +00001270 // Add working directory to context for tool execution
1271 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1272
1273 // Execute the tools
1274 var err error
1275 results, err = a.convo.ToolResultContents(ctx, resp)
1276 if ctx.Err() != nil { // e.g. the user canceled the operation
1277 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001278 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001279 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001280 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001281 a.pushToOutbox(ctx, errorMessage(err))
1282 }
1283 }
1284
1285 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001286 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001287 autoqualityMessages := a.processGitChanges(ctx)
1288
1289 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001290 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001291 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001292 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001293 return false, nil
1294 }
1295
1296 // Continue the conversation with tool results and any user messages
1297 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1298}
1299
1300// processGitChanges checks for new git commits and runs autoformatters if needed
1301func (a *Agent) processGitChanges(ctx context.Context) []string {
1302 // Check for git commits after tool execution
1303 newCommits, err := a.handleGitCommits(ctx)
1304 if err != nil {
1305 // Just log the error, don't stop execution
1306 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1307 return nil
1308 }
1309
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001310 // Run mechanical checks if there was exactly one new commit.
1311 if len(newCommits) != 1 {
1312 return nil
1313 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001314 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001315 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1316 msg := a.codereview.RunMechanicalChecks(ctx)
1317 if msg != "" {
1318 a.pushToOutbox(ctx, AgentMessage{
1319 Type: AutoMessageType,
1320 Content: msg,
1321 Timestamp: time.Now(),
1322 })
1323 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001324 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001325
1326 return autoqualityMessages
1327}
1328
1329// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001330func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001331 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001332 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001333 msgs, err := a.GatherMessages(ctx, false)
1334 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001335 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001336 return false, nil
1337 }
1338
1339 // Inject any auto-generated messages from quality checks
1340 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001341 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001342 }
1343
1344 // Handle cancellation by appending a message about it
1345 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001346 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001347 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001348 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001349 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1350 } else if err := a.convo.OverBudget(); err != nil {
1351 // Handle budget issues by appending a message about it
1352 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 -07001353 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001354 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1355 }
1356
1357 // Combine tool results with user messages
1358 results = append(results, msgs...)
1359
1360 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001361 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001362 resp, err := a.convo.SendMessage(llm.Message{
1363 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001364 Content: results,
1365 })
1366 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001367 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001368 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1369 return true, nil // Return true to continue the conversation, but with no response
1370 }
1371
Sean McCullough96b60dd2025-04-30 09:49:10 -07001372 // Transition back to processing LLM response
1373 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1374
Sean McCullough885a16a2025-04-30 02:49:25 +00001375 if cancelled {
1376 return false, nil
1377 }
1378
1379 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001380}
1381
1382func (a *Agent) overBudget(ctx context.Context) error {
1383 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001384 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001385 m := budgetMessage(err)
1386 m.Content = m.Content + "\n\nBudget reset."
1387 a.pushToOutbox(ctx, budgetMessage(err))
1388 a.convo.ResetBudget(a.originalBudget)
1389 return err
1390 }
1391 return nil
1392}
1393
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001394func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001395 // Collect all text content
1396 var allText strings.Builder
1397 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001398 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001399 if allText.Len() > 0 {
1400 allText.WriteString("\n\n")
1401 }
1402 allText.WriteString(content.Text)
1403 }
1404 }
1405 return allText.String()
1406}
1407
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001408func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001409 a.mu.Lock()
1410 defer a.mu.Unlock()
1411 return a.convo.CumulativeUsage()
1412}
1413
Earl Lee2e463fb2025-04-17 11:22:22 -07001414// Diff returns a unified diff of changes made since the agent was instantiated.
1415func (a *Agent) Diff(commit *string) (string, error) {
1416 if a.initialCommit == "" {
1417 return "", fmt.Errorf("no initial commit reference available")
1418 }
1419
1420 // Find the repository root
1421 ctx := context.Background()
1422
1423 // If a specific commit hash is provided, show just that commit's changes
1424 if commit != nil && *commit != "" {
1425 // Validate that the commit looks like a valid git SHA
1426 if !isValidGitSHA(*commit) {
1427 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1428 }
1429
1430 // Get the diff for just this commit
1431 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1432 cmd.Dir = a.repoRoot
1433 output, err := cmd.CombinedOutput()
1434 if err != nil {
1435 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1436 }
1437 return string(output), nil
1438 }
1439
1440 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1441 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1442 cmd.Dir = a.repoRoot
1443 output, err := cmd.CombinedOutput()
1444 if err != nil {
1445 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1446 }
1447
1448 return string(output), nil
1449}
1450
1451// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1452func (a *Agent) InitialCommit() string {
1453 return a.initialCommit
1454}
1455
1456// handleGitCommits() highlights new commits to the user. When running
1457// under docker, new HEADs are pushed to a branch according to the title.
1458func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1459 if a.repoRoot == "" {
1460 return nil, nil
1461 }
1462
1463 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1464 if err != nil {
1465 return nil, err
1466 }
1467 if head == a.lastHEAD {
1468 return nil, nil // nothing to do
1469 }
1470 defer func() {
1471 a.lastHEAD = head
1472 }()
1473
1474 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1475 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1476 // to the last 100 commits.
1477 var commits []*GitCommit
1478
1479 // Get commits since the initial commit
1480 // Format: <hash>\0<subject>\0<body>\0
1481 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1482 // Limit to 100 commits to avoid overwhelming the user
1483 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1484 cmd.Dir = a.repoRoot
1485 output, err := cmd.Output()
1486 if err != nil {
1487 return nil, fmt.Errorf("failed to get git log: %w", err)
1488 }
1489
1490 // Parse git log output and filter out already seen commits
1491 parsedCommits := parseGitLog(string(output))
1492
1493 var headCommit *GitCommit
1494
1495 // Filter out commits we've already seen
1496 for _, commit := range parsedCommits {
1497 if commit.Hash == head {
1498 headCommit = &commit
1499 }
1500
1501 // Skip if we've seen this commit before. If our head has changed, always include that.
1502 if a.seenCommits[commit.Hash] && commit.Hash != head {
1503 continue
1504 }
1505
1506 // Mark this commit as seen
1507 a.seenCommits[commit.Hash] = true
1508
1509 // Add to our list of new commits
1510 commits = append(commits, &commit)
1511 }
1512
1513 if a.gitRemoteAddr != "" {
1514 if headCommit == nil {
1515 // I think this can only happen if we have a bug or if there's a race.
1516 headCommit = &GitCommit{}
1517 headCommit.Hash = head
1518 headCommit.Subject = "unknown"
1519 commits = append(commits, headCommit)
1520 }
1521
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001522 branch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
Earl Lee2e463fb2025-04-17 11:22:22 -07001523
1524 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1525 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1526 // then use push with lease to replace.
1527 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1528 cmd.Dir = a.workingDir
1529 if out, err := cmd.CombinedOutput(); err != nil {
1530 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1531 } else {
1532 headCommit.PushedBranch = branch
1533 }
1534 }
1535
1536 // If we found new commits, create a message
1537 if len(commits) > 0 {
1538 msg := AgentMessage{
1539 Type: CommitMessageType,
1540 Timestamp: time.Now(),
1541 Commits: commits,
1542 }
1543 a.pushToOutbox(ctx, msg)
1544 }
1545 return commits, nil
1546}
1547
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001548func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001549 return strings.Map(func(r rune) rune {
1550 // lowercase
1551 if r >= 'A' && r <= 'Z' {
1552 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001553 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001554 // replace spaces with dashes
1555 if r == ' ' {
1556 return '-'
1557 }
1558 // allow alphanumerics and dashes
1559 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1560 return r
1561 }
1562 return -1
1563 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001564}
1565
1566// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1567// and returns an array of GitCommit structs.
1568func parseGitLog(output string) []GitCommit {
1569 var commits []GitCommit
1570
1571 // No output means no commits
1572 if len(output) == 0 {
1573 return commits
1574 }
1575
1576 // Split by NULL byte
1577 parts := strings.Split(output, "\x00")
1578
1579 // Process in triplets (hash, subject, body)
1580 for i := 0; i < len(parts); i++ {
1581 // Skip empty parts
1582 if parts[i] == "" {
1583 continue
1584 }
1585
1586 // This should be a hash
1587 hash := strings.TrimSpace(parts[i])
1588
1589 // Make sure we have at least a subject part available
1590 if i+1 >= len(parts) {
1591 break // No more parts available
1592 }
1593
1594 // Get the subject
1595 subject := strings.TrimSpace(parts[i+1])
1596
1597 // Get the body if available
1598 body := ""
1599 if i+2 < len(parts) {
1600 body = strings.TrimSpace(parts[i+2])
1601 }
1602
1603 // Skip to the next triplet
1604 i += 2
1605
1606 commits = append(commits, GitCommit{
1607 Hash: hash,
1608 Subject: subject,
1609 Body: body,
1610 })
1611 }
1612
1613 return commits
1614}
1615
1616func repoRoot(ctx context.Context, dir string) (string, error) {
1617 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1618 stderr := new(strings.Builder)
1619 cmd.Stderr = stderr
1620 cmd.Dir = dir
1621 out, err := cmd.Output()
1622 if err != nil {
1623 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1624 }
1625 return strings.TrimSpace(string(out)), nil
1626}
1627
1628func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1629 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1630 stderr := new(strings.Builder)
1631 cmd.Stderr = stderr
1632 cmd.Dir = dir
1633 out, err := cmd.Output()
1634 if err != nil {
1635 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1636 }
1637 // TODO: validate that out is valid hex
1638 return strings.TrimSpace(string(out)), nil
1639}
1640
1641// isValidGitSHA validates if a string looks like a valid git SHA hash.
1642// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1643func isValidGitSHA(sha string) bool {
1644 // Git SHA must be a hexadecimal string with at least 4 characters
1645 if len(sha) < 4 || len(sha) > 40 {
1646 return false
1647 }
1648
1649 // Check if the string only contains hexadecimal characters
1650 for _, char := range sha {
1651 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1652 return false
1653 }
1654 }
1655
1656 return true
1657}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001658
1659// getGitOrigin returns the URL of the git remote 'origin' if it exists
1660func getGitOrigin(ctx context.Context, dir string) string {
1661 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1662 cmd.Dir = dir
1663 stderr := new(strings.Builder)
1664 cmd.Stderr = stderr
1665 out, err := cmd.Output()
1666 if err != nil {
1667 return ""
1668 }
1669 return strings.TrimSpace(string(out))
1670}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001671
1672func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1673 cmd := exec.CommandContext(ctx, "git", "stash")
1674 cmd.Dir = workingDir
1675 if out, err := cmd.CombinedOutput(); err != nil {
1676 return fmt.Errorf("git stash: %s: %v", out, err)
1677 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001678 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001679 cmd.Dir = workingDir
1680 if out, err := cmd.CombinedOutput(); err != nil {
1681 return fmt.Errorf("git fetch: %s: %w", out, err)
1682 }
1683 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1684 cmd.Dir = workingDir
1685 if out, err := cmd.CombinedOutput(); err != nil {
1686 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1687 }
1688 a.lastHEAD = revision
1689 a.initialCommit = revision
1690 return nil
1691}
1692
1693func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1694 a.mu.Lock()
1695 a.title = ""
1696 a.firstMessageIndex = len(a.history)
1697 a.convo = a.initConvo()
1698 gitReset := func() error {
1699 if a.config.InDocker && rev != "" {
1700 err := a.initGitRevision(ctx, a.workingDir, rev)
1701 if err != nil {
1702 return err
1703 }
1704 } else if !a.config.InDocker && rev != "" {
1705 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1706 }
1707 return nil
1708 }
1709 err := gitReset()
1710 a.mu.Unlock()
1711 if err != nil {
1712 a.pushToOutbox(a.config.Context, errorMessage(err))
1713 }
1714
1715 a.pushToOutbox(a.config.Context, AgentMessage{
1716 Type: AgentMessageType, Content: "Conversation restarted.",
1717 })
1718 if initialPrompt != "" {
1719 a.UserMessage(ctx, initialPrompt)
1720 }
1721 return nil
1722}
1723
1724func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1725 msg := `The user has requested a suggestion for a re-prompt.
1726
1727 Given the current conversation thus far, suggest a re-prompt that would
1728 capture the instructions and feedback so far, as well as any
1729 research or other information that would be helpful in implementing
1730 the task.
1731
1732 Reply with ONLY the reprompt text.
1733 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001734 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001735 // By doing this in a subconversation, the agent doesn't call tools (because
1736 // there aren't any), and there's not a concurrency risk with on-going other
1737 // outstanding conversations.
1738 convo := a.convo.SubConvoWithHistory()
1739 resp, err := convo.SendMessage(userMessage)
1740 if err != nil {
1741 a.pushToOutbox(ctx, errorMessage(err))
1742 return "", err
1743 }
1744 textContent := collectTextContent(resp)
1745 return textContent, nil
1746}
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001747
1748// systemPromptData contains the data used to render the system prompt template
1749type systemPromptData struct {
1750 EditPrompt string
1751 ClientGOOS string
1752 ClientGOARCH string
1753 WorkingDir string
1754 RepoRoot string
1755 InitialCommit string
1756}
1757
1758// renderSystemPrompt renders the system prompt template.
1759func (a *Agent) renderSystemPrompt() string {
1760 // Determine the appropriate edit prompt based on config
1761 var editPrompt string
1762 if a.config.UseAnthropicEdit {
1763 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."
1764 } else {
1765 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1766 }
1767
1768 data := systemPromptData{
1769 EditPrompt: editPrompt,
1770 ClientGOOS: a.config.ClientGOOS,
1771 ClientGOARCH: a.config.ClientGOARCH,
1772 WorkingDir: a.workingDir,
1773 RepoRoot: a.repoRoot,
1774 InitialCommit: a.initialCommit,
1775 }
1776
1777 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1778 if err != nil {
1779 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1780 }
1781 buf := new(strings.Builder)
1782 err = tmpl.Execute(buf, data)
1783 if err != nil {
1784 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1785 }
1786 return buf.String()
1787}