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