blob: c10391968c66734090e5758a0300006ab199767d [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"
29 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070030)
31
32const (
33 userCancelMessage = "user requested agent to stop handling responses"
34)
35
Philip Zeyligerb7c58752025-05-01 10:10:17 -070036type MessageIterator interface {
37 // Next blocks until the next message is available. It may
38 // return nil if the underlying iterator context is done.
39 Next() *AgentMessage
40 Close()
41}
42
Earl Lee2e463fb2025-04-17 11:22:22 -070043type CodingAgent interface {
44 // Init initializes an agent inside a docker container.
45 Init(AgentInit) error
46
47 // Ready returns a channel closed after Init successfully called.
48 Ready() <-chan struct{}
49
50 // URL reports the HTTP URL of this agent.
51 URL() string
52
53 // UserMessage enqueues a message to the agent and returns immediately.
54 UserMessage(ctx context.Context, msg string)
55
Philip Zeyligerb7c58752025-05-01 10:10:17 -070056 // Returns an iterator that finishes when the context is done and
57 // starts with the given message index.
58 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070059
60 // Loop begins the agent loop returns only when ctx is cancelled.
61 Loop(ctx context.Context)
62
Sean McCulloughedc88dc2025-04-30 02:55:01 +000063 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070064
65 CancelToolUse(toolUseID string, cause error) error
66
67 // Returns a subset of the agent's message history.
68 Messages(start int, end int) []AgentMessage
69
70 // Returns the current number of messages in the history
71 MessageCount() int
72
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070073 TotalUsage() conversation.CumulativeUsage
74 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070075
Earl Lee2e463fb2025-04-17 11:22:22 -070076 WorkingDir() string
77
78 // Diff returns a unified diff of changes made since the agent was instantiated.
79 // If commit is non-nil, it shows the diff for just that specific commit.
80 Diff(commit *string) (string, error)
81
82 // InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
83 InitialCommit() string
84
85 // Title returns the current title of the conversation.
86 Title() string
87
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000088 // BranchName returns the git branch name for the conversation.
89 BranchName() string
90
Earl Lee2e463fb2025-04-17 11:22:22 -070091 // OS returns the operating system of the client.
92 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +000093
Philip Zeyligerc72fff52025-04-29 20:17:54 +000094 // SessionID returns the unique session identifier.
95 SessionID() string
96
Philip Zeyliger99a9a022025-04-27 15:15:25 +000097 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
98 OutstandingLLMCallCount() int
99
100 // OutstandingToolCalls returns the names of outstanding tool calls.
101 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000102 OutsideOS() string
103 OutsideHostname() string
104 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000105 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000106 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
107 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700108
109 // RestartConversation resets the conversation history
110 RestartConversation(ctx context.Context, rev string, initialPrompt string) error
111 // SuggestReprompt suggests a re-prompt based on the current conversation.
112 SuggestReprompt(ctx context.Context) (string, error)
113 // IsInContainer returns true if the agent is running in a container
114 IsInContainer() bool
115 // FirstMessageIndex returns the index of the first message in the current conversation
116 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700117
118 CurrentStateName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700119}
120
121type CodingAgentMessageType string
122
123const (
124 UserMessageType CodingAgentMessageType = "user"
125 AgentMessageType CodingAgentMessageType = "agent"
126 ErrorMessageType CodingAgentMessageType = "error"
127 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
128 ToolUseMessageType CodingAgentMessageType = "tool"
129 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
130 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
131
132 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
133)
134
135type AgentMessage struct {
136 Type CodingAgentMessageType `json:"type"`
137 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
138 EndOfTurn bool `json:"end_of_turn"`
139
140 Content string `json:"content"`
141 ToolName string `json:"tool_name,omitempty"`
142 ToolInput string `json:"input,omitempty"`
143 ToolResult string `json:"tool_result,omitempty"`
144 ToolError bool `json:"tool_error,omitempty"`
145 ToolCallId string `json:"tool_call_id,omitempty"`
146
147 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
148 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
149
Sean McCulloughd9f13372025-04-21 15:08:49 -0700150 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
151 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
152
Earl Lee2e463fb2025-04-17 11:22:22 -0700153 // Commits is a list of git commits for a commit message
154 Commits []*GitCommit `json:"commits,omitempty"`
155
156 Timestamp time.Time `json:"timestamp"`
157 ConversationID string `json:"conversation_id"`
158 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700159 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700160
161 // Message timing information
162 StartTime *time.Time `json:"start_time,omitempty"`
163 EndTime *time.Time `json:"end_time,omitempty"`
164 Elapsed *time.Duration `json:"elapsed,omitempty"`
165
166 // Turn duration - the time taken for a complete agent turn
167 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
168
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000169 // HideOutput indicates that this message should not be rendered in the UI.
170 // This is useful for subconversations that generate output that shouldn't be shown to the user.
171 HideOutput bool `json:"hide_output,omitempty"`
172
Earl Lee2e463fb2025-04-17 11:22:22 -0700173 Idx int `json:"idx"`
174}
175
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000176// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700177func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700178 if convo == nil {
179 m.ConversationID = ""
180 m.ParentConversationID = nil
181 return
182 }
183 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000184 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700185 if convo.Parent != nil {
186 m.ParentConversationID = &convo.Parent.ID
187 }
188}
189
Earl Lee2e463fb2025-04-17 11:22:22 -0700190// GitCommit represents a single git commit for a commit message
191type GitCommit struct {
192 Hash string `json:"hash"` // Full commit hash
193 Subject string `json:"subject"` // Commit subject line
194 Body string `json:"body"` // Full commit message body
195 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
196}
197
198// ToolCall represents a single tool call within an agent message
199type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700200 Name string `json:"name"`
201 Input string `json:"input"`
202 ToolCallId string `json:"tool_call_id"`
203 ResultMessage *AgentMessage `json:"result_message,omitempty"`
204 Args string `json:"args,omitempty"`
205 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700206}
207
208func (a *AgentMessage) Attr() slog.Attr {
209 var attrs []any = []any{
210 slog.String("type", string(a.Type)),
211 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700212 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700213 if a.EndOfTurn {
214 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
215 }
216 if a.Content != "" {
217 attrs = append(attrs, slog.String("content", a.Content))
218 }
219 if a.ToolName != "" {
220 attrs = append(attrs, slog.String("tool_name", a.ToolName))
221 }
222 if a.ToolInput != "" {
223 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
224 }
225 if a.Elapsed != nil {
226 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
227 }
228 if a.TurnDuration != nil {
229 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
230 }
231 if a.ToolResult != "" {
232 attrs = append(attrs, slog.String("tool_result", a.ToolResult))
233 }
234 if a.ToolError {
235 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
236 }
237 if len(a.ToolCalls) > 0 {
238 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
239 for i, tc := range a.ToolCalls {
240 toolCallAttrs = append(toolCallAttrs, slog.Group(
241 fmt.Sprintf("tool_call_%d", i),
242 slog.String("name", tc.Name),
243 slog.String("input", tc.Input),
244 ))
245 }
246 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
247 }
248 if a.ConversationID != "" {
249 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
250 }
251 if a.ParentConversationID != nil {
252 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
253 }
254 if a.Usage != nil && !a.Usage.IsZero() {
255 attrs = append(attrs, a.Usage.Attr())
256 }
257 // TODO: timestamp, convo ids, idx?
258 return slog.Group("agent_message", attrs...)
259}
260
261func errorMessage(err error) AgentMessage {
262 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
263 if os.Getenv(("DEBUG")) == "1" {
264 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
265 }
266
267 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
268}
269
270func budgetMessage(err error) AgentMessage {
271 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
272}
273
274// ConvoInterface defines the interface for conversation interactions
275type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700276 CumulativeUsage() conversation.CumulativeUsage
277 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700278 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700279 SendMessage(message llm.Message) (*llm.Response, error)
280 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700281 GetID() string
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700282 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error)
283 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700284 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700285 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700286}
287
288type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700289 convo ConvoInterface
290 config AgentConfig // config for this agent
291 workingDir string
292 repoRoot string // workingDir may be a subdir of repoRoot
293 url string
294 firstMessageIndex int // index of the first message in the current conversation
295 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
296 initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
297 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000298 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700299 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000300 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700301 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700302 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700303 title string
304 branchName string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000305 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700306 // State machine to track agent state
307 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000308 // Outside information
309 outsideHostname string
310 outsideOS string
311 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000312 // URL of the git remote 'origin' if it exists
313 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700314
315 // Time when the current turn started (reset at the beginning of InnerLoop)
316 startOfTurn time.Time
317
318 // Inbox - for messages from the user to the agent.
319 // sent on by UserMessage
320 // . e.g. when user types into the chat textarea
321 // read from by GatherMessages
322 inbox chan string
323
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000324 // protects cancelTurn
325 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700326 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000327 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700328
329 // protects following
330 mu sync.Mutex
331
332 // Stores all messages for this agent
333 history []AgentMessage
334
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700335 // Iterators add themselves here when they're ready to be notified of new messages.
336 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700337
338 // Track git commits we've already seen (by hash)
339 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000340
341 // Track outstanding LLM call IDs
342 outstandingLLMCalls map[string]struct{}
343
344 // Track outstanding tool calls by ID with their names
345 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700346}
347
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700348// NewIterator implements CodingAgent.
349func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
350 a.mu.Lock()
351 defer a.mu.Unlock()
352
353 return &MessageIteratorImpl{
354 agent: a,
355 ctx: ctx,
356 nextMessageIdx: nextMessageIdx,
357 ch: make(chan *AgentMessage, 100),
358 }
359}
360
361type MessageIteratorImpl struct {
362 agent *Agent
363 ctx context.Context
364 nextMessageIdx int
365 ch chan *AgentMessage
366 subscribed bool
367}
368
369func (m *MessageIteratorImpl) Close() {
370 m.agent.mu.Lock()
371 defer m.agent.mu.Unlock()
372 // Delete ourselves from the subscribers list
373 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
374 return x == m.ch
375 })
376 close(m.ch)
377}
378
379func (m *MessageIteratorImpl) Next() *AgentMessage {
380 // We avoid subscription at creation to let ourselves catch up to "current state"
381 // before subscribing.
382 if !m.subscribed {
383 m.agent.mu.Lock()
384 if m.nextMessageIdx < len(m.agent.history) {
385 msg := &m.agent.history[m.nextMessageIdx]
386 m.nextMessageIdx++
387 m.agent.mu.Unlock()
388 return msg
389 }
390 // The next message doesn't exist yet, so let's subscribe
391 m.agent.subscribers = append(m.agent.subscribers, m.ch)
392 m.subscribed = true
393 m.agent.mu.Unlock()
394 }
395
396 for {
397 select {
398 case <-m.ctx.Done():
399 m.agent.mu.Lock()
400 // Delete ourselves from the subscribers list
401 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
402 return x == m.ch
403 })
404 m.subscribed = false
405 m.agent.mu.Unlock()
406 return nil
407 case msg, ok := <-m.ch:
408 if !ok {
409 // Close may have been called
410 return nil
411 }
412 if msg.Idx == m.nextMessageIdx {
413 m.nextMessageIdx++
414 return msg
415 }
416 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
417 panic("out of order message")
418 }
419 }
420}
421
Sean McCulloughd9d45812025-04-30 16:53:41 -0700422// Assert that Agent satisfies the CodingAgent interface.
423var _ CodingAgent = &Agent{}
424
425// StateName implements CodingAgent.
426func (a *Agent) CurrentStateName() string {
427 if a.stateMachine == nil {
428 return ""
429 }
430 return a.stateMachine.currentState.String()
431}
432
Earl Lee2e463fb2025-04-17 11:22:22 -0700433func (a *Agent) URL() string { return a.url }
434
435// Title returns the current title of the conversation.
436// If no title has been set, returns an empty string.
437func (a *Agent) Title() string {
438 a.mu.Lock()
439 defer a.mu.Unlock()
440 return a.title
441}
442
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000443// BranchName returns the git branch name for the conversation.
444func (a *Agent) BranchName() string {
445 a.mu.Lock()
446 defer a.mu.Unlock()
447 return a.branchName
448}
449
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000450// OutstandingLLMCallCount returns the number of outstanding LLM calls.
451func (a *Agent) OutstandingLLMCallCount() int {
452 a.mu.Lock()
453 defer a.mu.Unlock()
454 return len(a.outstandingLLMCalls)
455}
456
457// OutstandingToolCalls returns the names of outstanding tool calls.
458func (a *Agent) OutstandingToolCalls() []string {
459 a.mu.Lock()
460 defer a.mu.Unlock()
461
462 tools := make([]string, 0, len(a.outstandingToolCalls))
463 for _, toolName := range a.outstandingToolCalls {
464 tools = append(tools, toolName)
465 }
466 return tools
467}
468
Earl Lee2e463fb2025-04-17 11:22:22 -0700469// OS returns the operating system of the client.
470func (a *Agent) OS() string {
471 return a.config.ClientGOOS
472}
473
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000474func (a *Agent) SessionID() string {
475 return a.config.SessionID
476}
477
Philip Zeyliger18532b22025-04-23 21:11:46 +0000478// OutsideOS returns the operating system of the outside system.
479func (a *Agent) OutsideOS() string {
480 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000481}
482
Philip Zeyliger18532b22025-04-23 21:11:46 +0000483// OutsideHostname returns the hostname of the outside system.
484func (a *Agent) OutsideHostname() string {
485 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000486}
487
Philip Zeyliger18532b22025-04-23 21:11:46 +0000488// OutsideWorkingDir returns the working directory on the outside system.
489func (a *Agent) OutsideWorkingDir() string {
490 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000491}
492
493// GitOrigin returns the URL of the git remote 'origin' if it exists.
494func (a *Agent) GitOrigin() string {
495 return a.gitOrigin
496}
497
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000498func (a *Agent) OpenBrowser(url string) {
499 if !a.IsInContainer() {
500 browser.Open(url)
501 return
502 }
503 // We're in Docker, need to send a request to the Git server
504 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700505 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000506 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700507 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000508 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700509 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000510 return
511 }
512 defer resp.Body.Close()
513 if resp.StatusCode == http.StatusOK {
514 return
515 }
516 body, _ := io.ReadAll(resp.Body)
517 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
518}
519
Sean McCullough96b60dd2025-04-30 09:49:10 -0700520// CurrentState returns the current state of the agent's state machine.
521func (a *Agent) CurrentState() State {
522 return a.stateMachine.CurrentState()
523}
524
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700525func (a *Agent) IsInContainer() bool {
526 return a.config.InDocker
527}
528
529func (a *Agent) FirstMessageIndex() int {
530 a.mu.Lock()
531 defer a.mu.Unlock()
532 return a.firstMessageIndex
533}
534
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000535// SetTitle sets the title of the conversation.
536func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700537 a.mu.Lock()
538 defer a.mu.Unlock()
539 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000540}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700541
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000542// SetBranch sets the branch name of the conversation.
543func (a *Agent) SetBranch(branchName string) {
544 a.mu.Lock()
545 defer a.mu.Unlock()
546 a.branchName = branchName
Earl Lee2e463fb2025-04-17 11:22:22 -0700547}
548
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000549// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700550func (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 +0000551 // Track the tool call
552 a.mu.Lock()
553 a.outstandingToolCalls[id] = toolName
554 a.mu.Unlock()
555}
556
Earl Lee2e463fb2025-04-17 11:22:22 -0700557// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700558func (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 +0000559 // Remove the tool call from outstanding calls
560 a.mu.Lock()
561 delete(a.outstandingToolCalls, toolID)
562 a.mu.Unlock()
563
Earl Lee2e463fb2025-04-17 11:22:22 -0700564 m := AgentMessage{
565 Type: ToolUseMessageType,
566 Content: content.Text,
567 ToolResult: content.ToolResult,
568 ToolError: content.ToolError,
569 ToolName: toolName,
570 ToolInput: string(toolInput),
571 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700572 StartTime: content.ToolUseStartTime,
573 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700574 }
575
576 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700577 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
578 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700579 m.Elapsed = &elapsed
580 }
581
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700582 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700583 a.pushToOutbox(ctx, m)
584}
585
586// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700587func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000588 a.mu.Lock()
589 defer a.mu.Unlock()
590 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700591 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
592}
593
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700594// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700595// that need to be displayed (as well as tool calls that we send along when
596// they're done). (It would be reasonable to also mention tool calls when they're
597// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700598func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000599 // Remove the LLM call from outstanding calls
600 a.mu.Lock()
601 delete(a.outstandingLLMCalls, id)
602 a.mu.Unlock()
603
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700604 if resp == nil {
605 // LLM API call failed
606 m := AgentMessage{
607 Type: ErrorMessageType,
608 Content: "API call failed, type 'continue' to try again",
609 }
610 m.SetConvo(convo)
611 a.pushToOutbox(ctx, m)
612 return
613 }
614
Earl Lee2e463fb2025-04-17 11:22:22 -0700615 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700616 if convo.Parent == nil { // subconvos never end the turn
617 switch resp.StopReason {
618 case llm.StopReasonToolUse:
619 // Check whether any of the tool calls are for tools that should end the turn
620 ToolSearch:
621 for _, part := range resp.Content {
622 if part.Type != llm.ContentTypeToolUse {
623 continue
624 }
Sean McCullough021557a2025-05-05 23:20:53 +0000625 // Find the tool by name
626 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700627 if tool.Name == part.ToolName {
628 endOfTurn = tool.EndsTurn
629 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000630 }
631 }
Sean McCullough021557a2025-05-05 23:20:53 +0000632 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700633 default:
634 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000635 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700636 }
637 m := AgentMessage{
638 Type: AgentMessageType,
639 Content: collectTextContent(resp),
640 EndOfTurn: endOfTurn,
641 Usage: &resp.Usage,
642 StartTime: resp.StartTime,
643 EndTime: resp.EndTime,
644 }
645
646 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700647 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700648 var toolCalls []ToolCall
649 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700650 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700651 toolCalls = append(toolCalls, ToolCall{
652 Name: part.ToolName,
653 Input: string(part.ToolInput),
654 ToolCallId: part.ID,
655 })
656 }
657 }
658 m.ToolCalls = toolCalls
659 }
660
661 // Calculate the elapsed time if both start and end times are set
662 if resp.StartTime != nil && resp.EndTime != nil {
663 elapsed := resp.EndTime.Sub(*resp.StartTime)
664 m.Elapsed = &elapsed
665 }
666
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700667 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700668 a.pushToOutbox(ctx, m)
669}
670
671// WorkingDir implements CodingAgent.
672func (a *Agent) WorkingDir() string {
673 return a.workingDir
674}
675
676// MessageCount implements CodingAgent.
677func (a *Agent) MessageCount() int {
678 a.mu.Lock()
679 defer a.mu.Unlock()
680 return len(a.history)
681}
682
683// Messages implements CodingAgent.
684func (a *Agent) Messages(start int, end int) []AgentMessage {
685 a.mu.Lock()
686 defer a.mu.Unlock()
687 return slices.Clone(a.history[start:end])
688}
689
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700690func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700691 return a.originalBudget
692}
693
694// AgentConfig contains configuration for creating a new Agent.
695type AgentConfig struct {
696 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700697 Service llm.Service
698 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700699 GitUsername string
700 GitEmail string
701 SessionID string
702 ClientGOOS string
703 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700704 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700705 UseAnthropicEdit bool
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000706 OneShot bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000707 // Outside information
708 OutsideHostname string
709 OutsideOS string
710 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700711}
712
713// NewAgent creates a new Agent.
714// It is not usable until Init() is called.
715func NewAgent(config AgentConfig) *Agent {
716 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000717 config: config,
718 ready: make(chan struct{}),
719 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700720 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000721 startedAt: time.Now(),
722 originalBudget: config.Budget,
723 seenCommits: make(map[string]bool),
724 outsideHostname: config.OutsideHostname,
725 outsideOS: config.OutsideOS,
726 outsideWorkingDir: config.OutsideWorkingDir,
727 outstandingLLMCalls: make(map[string]struct{}),
728 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700729 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700730 }
731 return agent
732}
733
734type AgentInit struct {
735 WorkingDir string
736 NoGit bool // only for testing
737
738 InDocker bool
739 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000740 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700741 GitRemoteAddr string
742 HostAddr string
743}
744
745func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700746 if a.convo != nil {
747 return fmt.Errorf("Agent.Init: already initialized")
748 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700749 ctx := a.config.Context
750 if ini.InDocker {
751 cmd := exec.CommandContext(ctx, "git", "stash")
752 cmd.Dir = ini.WorkingDir
753 if out, err := cmd.CombinedOutput(); err != nil {
754 return fmt.Errorf("git stash: %s: %v", out, err)
755 }
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700756 // sketch-host is a git repo hosted by "outtie sketch". When it notices a 'git fetch',
757 // it runs "git fetch" underneath the covers to get its latest commits. By configuring
758 // an additional remote.sketch-host.fetch, we make "origin/main" on innie sketch look like
759 // origin/main on outtie sketch, which should make it easier to rebase.
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700760 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
761 cmd.Dir = ini.WorkingDir
762 if out, err := cmd.CombinedOutput(); err != nil {
763 return fmt.Errorf("git remote add: %s: %v", out, err)
764 }
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700765 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.sketch-host.fetch",
766 "+refs/heads/feature/*:refs/remotes/origin/feature/*")
767 cmd.Dir = ini.WorkingDir
768 if out, err := cmd.CombinedOutput(); err != nil {
769 return fmt.Errorf("git config --add: %s: %v", out, err)
770 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000771 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700772 cmd.Dir = ini.WorkingDir
773 if out, err := cmd.CombinedOutput(); err != nil {
774 return fmt.Errorf("git fetch: %s: %w", out, err)
775 }
776 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
777 cmd.Dir = ini.WorkingDir
778 if out, err := cmd.CombinedOutput(); err != nil {
779 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
780 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700781 a.lastHEAD = ini.Commit
782 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000783 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700784 a.initialCommit = ini.Commit
785 if ini.HostAddr != "" {
786 a.url = "http://" + ini.HostAddr
787 }
788 }
789 a.workingDir = ini.WorkingDir
790
791 if !ini.NoGit {
792 repoRoot, err := repoRoot(ctx, a.workingDir)
793 if err != nil {
794 return fmt.Errorf("repoRoot: %w", err)
795 }
796 a.repoRoot = repoRoot
797
798 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
799 if err != nil {
800 return fmt.Errorf("resolveRef: %w", err)
801 }
802 a.initialCommit = commitHash
803
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000804 if experiment.Enabled("memory") {
805 slog.Info("running codebase analysis")
806 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
807 if err != nil {
808 slog.Warn("failed to analyze codebase", "error", err)
809 }
810 a.codebase = codebase
811 }
812
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000813 llmCodeReview := codereview.NoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700814 if experiment.Enabled("llm_review") {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000815 llmCodeReview = codereview.DoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700816 }
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000817 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit, llmCodeReview)
Earl Lee2e463fb2025-04-17 11:22:22 -0700818 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000819 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700820 }
821 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000822
823 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700824 }
825 a.lastHEAD = a.initialCommit
826 a.convo = a.initConvo()
827 close(a.ready)
828 return nil
829}
830
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700831//go:embed agent_system_prompt.txt
832var agentSystemPrompt string
833
Earl Lee2e463fb2025-04-17 11:22:22 -0700834// initConvo initializes the conversation.
835// It must not be called until all agent fields are initialized,
836// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700837func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700838 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700839 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700840 convo.PromptCaching = true
841 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000842 convo.SystemPrompt = a.renderSystemPrompt()
Earl Lee2e463fb2025-04-17 11:22:22 -0700843
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000844 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
845 bashPermissionCheck := func(command string) error {
846 // Check if branch name is set
847 a.mu.Lock()
848 branchSet := a.branchName != ""
849 a.mu.Unlock()
850
851 // If branch is set, all commands are allowed
852 if branchSet {
853 return nil
854 }
855
856 // If branch is not set, check if this is a git commit command
857 willCommit, err := bashkit.WillRunGitCommit(command)
858 if err != nil {
859 // If there's an error checking, we should allow the command to proceed
860 return nil
861 }
862
863 // If it's a git commit and branch is not set, return an error
864 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000865 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000866 }
867
868 return nil
869 }
870
871 // Create a custom bash tool with the permission check
872 bashTool := claudetool.NewBashTool(bashPermissionCheck)
873
Earl Lee2e463fb2025-04-17 11:22:22 -0700874 // Register all tools with the conversation
875 // When adding, removing, or modifying tools here, double-check that the termui tool display
876 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000877
878 var browserTools []*llm.Tool
879 // Add browser tools if enabled
880 // if experiment.Enabled("browser") {
881 if true {
882 bTools, browserCleanup := browse.RegisterBrowserTools(a.config.Context)
883 // Add cleanup function to context cancel
884 go func() {
885 <-a.config.Context.Done()
886 browserCleanup()
887 }()
888 browserTools = bTools
889 }
890
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700891 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000892 bashTool, claudetool.Keyword,
Josh Bleecher Snyder93202652025-05-08 02:05:57 +0000893 claudetool.Think, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000894 a.codereview.Tool(),
895 }
896
897 // One-shot mode is non-interactive, multiple choice requires human response
898 if !a.config.OneShot {
899 convo.Tools = append(convo.Tools, a.multipleChoiceTool())
Earl Lee2e463fb2025-04-17 11:22:22 -0700900 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000901
902 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -0700903 if a.config.UseAnthropicEdit {
904 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
905 } else {
906 convo.Tools = append(convo.Tools, claudetool.Patch)
907 }
908 convo.Listener = a
909 return convo
910}
911
Sean McCullough485afc62025-04-28 14:28:39 -0700912func (a *Agent) multipleChoiceTool() *llm.Tool {
913 ret := &llm.Tool{
914 Name: "multiplechoice",
915 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 +0000916 EndsTurn: true,
Sean McCullough485afc62025-04-28 14:28:39 -0700917 InputSchema: json.RawMessage(`{
918 "type": "object",
919 "description": "The question and a list of answers you would expect the user to choose from.",
920 "properties": {
921 "question": {
922 "type": "string",
923 "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?'"
924 },
925 "responseOptions": {
926 "type": "array",
927 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
928 "items": {
929 "type": "object",
930 "properties": {
931 "caption": {
932 "type": "string",
933 "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'"
934 },
935 "responseText": {
936 "type": "string",
937 "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'"
938 }
939 },
940 "required": ["caption", "responseText"]
941 }
942 }
943 },
944 "required": ["question", "responseOptions"]
945}`),
946 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
947 // The Run logic for "multiplchoice" tool is a no-op on the server.
948 // The UI will present a list of options for the user to select from,
949 // and that's it as far as "executing" the tool_use goes.
950 // When the user *does* select one of the presented options, that
951 // responseText gets sent as a chat message on behalf of the user.
952 return "end your turn and wait for the user to respond", nil
953 },
954 }
955 return ret
956}
957
958type MultipleChoiceOption struct {
959 Caption string `json:"caption"`
960 ResponseText string `json:"responseText"`
961}
962
963type MultipleChoiceParams struct {
964 Question string `json:"question"`
965 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
966}
967
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000968// branchExists reports whether branchName exists, either locally or in well-known remotes.
969func branchExists(dir, branchName string) bool {
970 refs := []string{
971 "refs/heads/",
972 "refs/remotes/origin/",
973 "refs/remotes/sketch-host/",
974 }
975 for _, ref := range refs {
976 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
977 cmd.Dir = dir
978 if cmd.Run() == nil { // exit code 0 means branch exists
979 return true
980 }
981 }
982 return false
983}
984
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000985func (a *Agent) titleTool() *llm.Tool {
986 description := `Sets the conversation title.`
987 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -0700988 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000989 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -0700990 InputSchema: json.RawMessage(`{
991 "type": "object",
992 "properties": {
993 "title": {
994 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000995 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -0700996 }
997 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000998 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -0700999}`),
1000 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
1001 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001002 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001003 }
1004 if err := json.Unmarshal(input, &params); err != nil {
1005 return "", err
1006 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001007
1008 // We don't allow changing the title once set to be consistent with the previous behavior
1009 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001010 t := a.Title()
1011 if t != "" {
1012 return "", fmt.Errorf("title already set to: %s", t)
1013 }
1014
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001015 if params.Title == "" {
1016 return "", fmt.Errorf("title parameter cannot be empty")
1017 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001018
1019 a.SetTitle(params.Title)
1020 response := fmt.Sprintf("Title set to %q", params.Title)
1021 return response, nil
1022 },
1023 }
1024 return titleTool
1025}
1026
1027func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001028 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 +00001029 preCommit := &llm.Tool{
1030 Name: "precommit",
1031 Description: description,
1032 InputSchema: json.RawMessage(`{
1033 "type": "object",
1034 "properties": {
1035 "branch_name": {
1036 "type": "string",
1037 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1038 }
1039 },
1040 "required": ["branch_name"]
1041}`),
1042 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
1043 var params struct {
1044 BranchName string `json:"branch_name"`
1045 }
1046 if err := json.Unmarshal(input, &params); err != nil {
1047 return "", err
1048 }
1049
1050 b := a.BranchName()
1051 if b != "" {
1052 return "", fmt.Errorf("branch already set to: %s", b)
1053 }
1054
1055 if params.BranchName == "" {
1056 return "", fmt.Errorf("branch_name parameter cannot be empty")
1057 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001058 if params.BranchName != cleanBranchName(params.BranchName) {
1059 return "", fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
1060 }
1061 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001062 if branchExists(a.workingDir, branchName) {
1063 return "", fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
1064 }
1065
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001066 a.SetBranch(branchName)
1067 response := fmt.Sprintf("Branch name set to %q", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001068
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001069 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1070 if err != nil {
1071 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1072 }
1073 if len(styleHint) > 0 {
1074 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001075 }
1076
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001077 return response, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001078 },
1079 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001080 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001081}
1082
1083func (a *Agent) Ready() <-chan struct{} {
1084 return a.ready
1085}
1086
1087func (a *Agent) UserMessage(ctx context.Context, msg string) {
1088 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1089 a.inbox <- msg
1090}
1091
Sean McCullough485afc62025-04-28 14:28:39 -07001092func (a *Agent) ToolResultMessage(ctx context.Context, toolCallID, msg string) {
1093 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg, ToolCallId: toolCallID})
1094 a.inbox <- msg
1095}
1096
Earl Lee2e463fb2025-04-17 11:22:22 -07001097func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1098 return a.convo.CancelToolUse(toolUseID, cause)
1099}
1100
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001101func (a *Agent) CancelTurn(cause error) {
1102 a.cancelTurnMu.Lock()
1103 defer a.cancelTurnMu.Unlock()
1104 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001105 // Force state transition to cancelled state
1106 ctx := a.config.Context
1107 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001108 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001109 }
1110}
1111
1112func (a *Agent) Loop(ctxOuter context.Context) {
1113 for {
1114 select {
1115 case <-ctxOuter.Done():
1116 return
1117 default:
1118 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001119 a.cancelTurnMu.Lock()
1120 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001121 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001122 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001123 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001124 a.cancelTurn = cancel
1125 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001126 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1127 if err != nil {
1128 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1129 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001130 cancel(nil)
1131 }
1132 }
1133}
1134
1135func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1136 if m.Timestamp.IsZero() {
1137 m.Timestamp = time.Now()
1138 }
1139
1140 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1141 if m.EndOfTurn && m.Type == AgentMessageType {
1142 turnDuration := time.Since(a.startOfTurn)
1143 m.TurnDuration = &turnDuration
1144 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1145 }
1146
Earl Lee2e463fb2025-04-17 11:22:22 -07001147 a.mu.Lock()
1148 defer a.mu.Unlock()
1149 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001150 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001151 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001152
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001153 // Notify all subscribers
1154 for _, ch := range a.subscribers {
1155 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001156 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001157}
1158
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001159func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1160 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001161 if block {
1162 select {
1163 case <-ctx.Done():
1164 return m, ctx.Err()
1165 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001166 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001167 }
1168 }
1169 for {
1170 select {
1171 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001172 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001173 default:
1174 return m, nil
1175 }
1176 }
1177}
1178
Sean McCullough885a16a2025-04-30 02:49:25 +00001179// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001180func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001181 // Reset the start of turn time
1182 a.startOfTurn = time.Now()
1183
Sean McCullough96b60dd2025-04-30 09:49:10 -07001184 // Transition to waiting for user input state
1185 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1186
Sean McCullough885a16a2025-04-30 02:49:25 +00001187 // Process initial user message
1188 initialResp, err := a.processUserMessage(ctx)
1189 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001190 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001191 return err
1192 }
1193
1194 // Handle edge case where both initialResp and err are nil
1195 if initialResp == nil {
1196 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001197 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1198
Sean McCullough9f4b8082025-04-30 17:34:07 +00001199 a.pushToOutbox(ctx, errorMessage(err))
1200 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001201 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001202
Earl Lee2e463fb2025-04-17 11:22:22 -07001203 // We do this as we go, but let's also do it at the end of the turn
1204 defer func() {
1205 if _, err := a.handleGitCommits(ctx); err != nil {
1206 // Just log the error, don't stop execution
1207 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1208 }
1209 }()
1210
Sean McCullougha1e0e492025-05-01 10:51:08 -07001211 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001212 resp := initialResp
1213 for {
1214 // Check if we are over budget
1215 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001216 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001217 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001218 }
1219
1220 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001221 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001222 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001223 break
1224 }
1225
Sean McCullough96b60dd2025-04-30 09:49:10 -07001226 // Transition to tool use requested state
1227 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1228
Sean McCullough885a16a2025-04-30 02:49:25 +00001229 // Handle tool execution
1230 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1231 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001232 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001233 }
1234
Sean McCullougha1e0e492025-05-01 10:51:08 -07001235 if toolResp == nil {
1236 return fmt.Errorf("cannot continue conversation with a nil tool response")
1237 }
1238
Sean McCullough885a16a2025-04-30 02:49:25 +00001239 // Set the response for the next iteration
1240 resp = toolResp
1241 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001242
1243 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001244}
1245
1246// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001247func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001248 // Wait for at least one message from the user
1249 msgs, err := a.GatherMessages(ctx, true)
1250 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001251 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001252 return nil, err
1253 }
1254
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001255 userMessage := llm.Message{
1256 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001257 Content: msgs,
1258 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001259
Sean McCullough96b60dd2025-04-30 09:49:10 -07001260 // Transition to sending to LLM state
1261 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1262
Sean McCullough885a16a2025-04-30 02:49:25 +00001263 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001264 resp, err := a.convo.SendMessage(userMessage)
1265 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001266 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001267 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001268 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001269 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001270
Sean McCullough96b60dd2025-04-30 09:49:10 -07001271 // Transition to processing LLM response state
1272 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1273
Sean McCullough885a16a2025-04-30 02:49:25 +00001274 return resp, nil
1275}
1276
1277// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001278func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1279 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001280 cancelled := false
1281
Sean McCullough96b60dd2025-04-30 09:49:10 -07001282 // Transition to checking for cancellation state
1283 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1284
Sean McCullough885a16a2025-04-30 02:49:25 +00001285 // Check if the operation was cancelled by the user
1286 select {
1287 case <-ctx.Done():
1288 // Don't actually run any of the tools, but rather build a response
1289 // for each tool_use message letting the LLM know that user canceled it.
1290 var err error
1291 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001292 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001293 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001294 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001295 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001296 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001297 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001298 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001299 // Transition to running tool state
1300 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1301
Sean McCullough885a16a2025-04-30 02:49:25 +00001302 // Add working directory to context for tool execution
1303 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1304
1305 // Execute the tools
1306 var err error
1307 results, err = a.convo.ToolResultContents(ctx, resp)
1308 if ctx.Err() != nil { // e.g. the user canceled the operation
1309 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001310 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001311 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001312 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001313 a.pushToOutbox(ctx, errorMessage(err))
1314 }
1315 }
1316
1317 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001318 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001319 autoqualityMessages := a.processGitChanges(ctx)
1320
1321 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001322 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001323 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001324 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001325 return false, nil
1326 }
1327
1328 // Continue the conversation with tool results and any user messages
1329 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1330}
1331
1332// processGitChanges checks for new git commits and runs autoformatters if needed
1333func (a *Agent) processGitChanges(ctx context.Context) []string {
1334 // Check for git commits after tool execution
1335 newCommits, err := a.handleGitCommits(ctx)
1336 if err != nil {
1337 // Just log the error, don't stop execution
1338 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1339 return nil
1340 }
1341
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001342 // Run mechanical checks if there was exactly one new commit.
1343 if len(newCommits) != 1 {
1344 return nil
1345 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001346 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001347 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1348 msg := a.codereview.RunMechanicalChecks(ctx)
1349 if msg != "" {
1350 a.pushToOutbox(ctx, AgentMessage{
1351 Type: AutoMessageType,
1352 Content: msg,
1353 Timestamp: time.Now(),
1354 })
1355 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001356 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001357
1358 return autoqualityMessages
1359}
1360
1361// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001362func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001363 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001364 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001365 msgs, err := a.GatherMessages(ctx, false)
1366 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001367 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001368 return false, nil
1369 }
1370
1371 // Inject any auto-generated messages from quality checks
1372 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001373 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001374 }
1375
1376 // Handle cancellation by appending a message about it
1377 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001378 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001379 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001380 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001381 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1382 } else if err := a.convo.OverBudget(); err != nil {
1383 // Handle budget issues by appending a message about it
1384 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 -07001385 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001386 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1387 }
1388
1389 // Combine tool results with user messages
1390 results = append(results, msgs...)
1391
1392 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001393 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001394 resp, err := a.convo.SendMessage(llm.Message{
1395 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001396 Content: results,
1397 })
1398 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001399 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001400 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1401 return true, nil // Return true to continue the conversation, but with no response
1402 }
1403
Sean McCullough96b60dd2025-04-30 09:49:10 -07001404 // Transition back to processing LLM response
1405 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1406
Sean McCullough885a16a2025-04-30 02:49:25 +00001407 if cancelled {
1408 return false, nil
1409 }
1410
1411 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001412}
1413
1414func (a *Agent) overBudget(ctx context.Context) error {
1415 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001416 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001417 m := budgetMessage(err)
1418 m.Content = m.Content + "\n\nBudget reset."
1419 a.pushToOutbox(ctx, budgetMessage(err))
1420 a.convo.ResetBudget(a.originalBudget)
1421 return err
1422 }
1423 return nil
1424}
1425
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001426func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001427 // Collect all text content
1428 var allText strings.Builder
1429 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001430 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001431 if allText.Len() > 0 {
1432 allText.WriteString("\n\n")
1433 }
1434 allText.WriteString(content.Text)
1435 }
1436 }
1437 return allText.String()
1438}
1439
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001440func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001441 a.mu.Lock()
1442 defer a.mu.Unlock()
1443 return a.convo.CumulativeUsage()
1444}
1445
Earl Lee2e463fb2025-04-17 11:22:22 -07001446// Diff returns a unified diff of changes made since the agent was instantiated.
1447func (a *Agent) Diff(commit *string) (string, error) {
1448 if a.initialCommit == "" {
1449 return "", fmt.Errorf("no initial commit reference available")
1450 }
1451
1452 // Find the repository root
1453 ctx := context.Background()
1454
1455 // If a specific commit hash is provided, show just that commit's changes
1456 if commit != nil && *commit != "" {
1457 // Validate that the commit looks like a valid git SHA
1458 if !isValidGitSHA(*commit) {
1459 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1460 }
1461
1462 // Get the diff for just this commit
1463 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1464 cmd.Dir = a.repoRoot
1465 output, err := cmd.CombinedOutput()
1466 if err != nil {
1467 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1468 }
1469 return string(output), nil
1470 }
1471
1472 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1473 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1474 cmd.Dir = a.repoRoot
1475 output, err := cmd.CombinedOutput()
1476 if err != nil {
1477 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1478 }
1479
1480 return string(output), nil
1481}
1482
1483// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1484func (a *Agent) InitialCommit() string {
1485 return a.initialCommit
1486}
1487
1488// handleGitCommits() highlights new commits to the user. When running
1489// under docker, new HEADs are pushed to a branch according to the title.
1490func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1491 if a.repoRoot == "" {
1492 return nil, nil
1493 }
1494
1495 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1496 if err != nil {
1497 return nil, err
1498 }
1499 if head == a.lastHEAD {
1500 return nil, nil // nothing to do
1501 }
1502 defer func() {
1503 a.lastHEAD = head
1504 }()
1505
1506 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1507 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1508 // to the last 100 commits.
1509 var commits []*GitCommit
1510
1511 // Get commits since the initial commit
1512 // Format: <hash>\0<subject>\0<body>\0
1513 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1514 // Limit to 100 commits to avoid overwhelming the user
1515 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1516 cmd.Dir = a.repoRoot
1517 output, err := cmd.Output()
1518 if err != nil {
1519 return nil, fmt.Errorf("failed to get git log: %w", err)
1520 }
1521
1522 // Parse git log output and filter out already seen commits
1523 parsedCommits := parseGitLog(string(output))
1524
1525 var headCommit *GitCommit
1526
1527 // Filter out commits we've already seen
1528 for _, commit := range parsedCommits {
1529 if commit.Hash == head {
1530 headCommit = &commit
1531 }
1532
1533 // Skip if we've seen this commit before. If our head has changed, always include that.
1534 if a.seenCommits[commit.Hash] && commit.Hash != head {
1535 continue
1536 }
1537
1538 // Mark this commit as seen
1539 a.seenCommits[commit.Hash] = true
1540
1541 // Add to our list of new commits
1542 commits = append(commits, &commit)
1543 }
1544
1545 if a.gitRemoteAddr != "" {
1546 if headCommit == nil {
1547 // I think this can only happen if we have a bug or if there's a race.
1548 headCommit = &GitCommit{}
1549 headCommit.Hash = head
1550 headCommit.Subject = "unknown"
1551 commits = append(commits, headCommit)
1552 }
1553
Philip Zeyliger113e2052025-05-09 21:59:40 +00001554 originalBranch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
1555 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001556
1557 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1558 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1559 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001560
1561 // Try up to 10 times with different branch names if the branch is checked out on the remote
1562 var out []byte
1563 var err error
1564 for retries := range 10 {
1565 if retries > 0 {
1566 // Add a numeric suffix to the branch name
1567 branch = fmt.Sprintf("%s%d", originalBranch, retries)
1568 }
1569
1570 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1571 cmd.Dir = a.workingDir
1572 out, err = cmd.CombinedOutput()
1573
1574 if err == nil {
1575 // Success! Break out of the retry loop
1576 break
1577 }
1578
1579 // Check if this is the "refusing to update checked out branch" error
1580 if !strings.Contains(string(out), "refusing to update checked out branch") {
1581 // This is a different error, so don't retry
1582 break
1583 }
1584
1585 // If we're on the last retry, we'll report the error
1586 if retries == 9 {
1587 break
1588 }
1589 }
1590
1591 if err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001592 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1593 } else {
1594 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001595 // Update the agent's branch name if we ended up using a different one
1596 if branch != originalBranch {
1597 a.branchName = branch
1598 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001599 }
1600 }
1601
1602 // If we found new commits, create a message
1603 if len(commits) > 0 {
1604 msg := AgentMessage{
1605 Type: CommitMessageType,
1606 Timestamp: time.Now(),
1607 Commits: commits,
1608 }
1609 a.pushToOutbox(ctx, msg)
1610 }
1611 return commits, nil
1612}
1613
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001614func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001615 return strings.Map(func(r rune) rune {
1616 // lowercase
1617 if r >= 'A' && r <= 'Z' {
1618 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001619 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001620 // replace spaces with dashes
1621 if r == ' ' {
1622 return '-'
1623 }
1624 // allow alphanumerics and dashes
1625 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1626 return r
1627 }
1628 return -1
1629 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001630}
1631
1632// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1633// and returns an array of GitCommit structs.
1634func parseGitLog(output string) []GitCommit {
1635 var commits []GitCommit
1636
1637 // No output means no commits
1638 if len(output) == 0 {
1639 return commits
1640 }
1641
1642 // Split by NULL byte
1643 parts := strings.Split(output, "\x00")
1644
1645 // Process in triplets (hash, subject, body)
1646 for i := 0; i < len(parts); i++ {
1647 // Skip empty parts
1648 if parts[i] == "" {
1649 continue
1650 }
1651
1652 // This should be a hash
1653 hash := strings.TrimSpace(parts[i])
1654
1655 // Make sure we have at least a subject part available
1656 if i+1 >= len(parts) {
1657 break // No more parts available
1658 }
1659
1660 // Get the subject
1661 subject := strings.TrimSpace(parts[i+1])
1662
1663 // Get the body if available
1664 body := ""
1665 if i+2 < len(parts) {
1666 body = strings.TrimSpace(parts[i+2])
1667 }
1668
1669 // Skip to the next triplet
1670 i += 2
1671
1672 commits = append(commits, GitCommit{
1673 Hash: hash,
1674 Subject: subject,
1675 Body: body,
1676 })
1677 }
1678
1679 return commits
1680}
1681
1682func repoRoot(ctx context.Context, dir string) (string, error) {
1683 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1684 stderr := new(strings.Builder)
1685 cmd.Stderr = stderr
1686 cmd.Dir = dir
1687 out, err := cmd.Output()
1688 if err != nil {
1689 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1690 }
1691 return strings.TrimSpace(string(out)), nil
1692}
1693
1694func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1695 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1696 stderr := new(strings.Builder)
1697 cmd.Stderr = stderr
1698 cmd.Dir = dir
1699 out, err := cmd.Output()
1700 if err != nil {
1701 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1702 }
1703 // TODO: validate that out is valid hex
1704 return strings.TrimSpace(string(out)), nil
1705}
1706
1707// isValidGitSHA validates if a string looks like a valid git SHA hash.
1708// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1709func isValidGitSHA(sha string) bool {
1710 // Git SHA must be a hexadecimal string with at least 4 characters
1711 if len(sha) < 4 || len(sha) > 40 {
1712 return false
1713 }
1714
1715 // Check if the string only contains hexadecimal characters
1716 for _, char := range sha {
1717 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1718 return false
1719 }
1720 }
1721
1722 return true
1723}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001724
1725// getGitOrigin returns the URL of the git remote 'origin' if it exists
1726func getGitOrigin(ctx context.Context, dir string) string {
1727 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1728 cmd.Dir = dir
1729 stderr := new(strings.Builder)
1730 cmd.Stderr = stderr
1731 out, err := cmd.Output()
1732 if err != nil {
1733 return ""
1734 }
1735 return strings.TrimSpace(string(out))
1736}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001737
1738func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1739 cmd := exec.CommandContext(ctx, "git", "stash")
1740 cmd.Dir = workingDir
1741 if out, err := cmd.CombinedOutput(); err != nil {
1742 return fmt.Errorf("git stash: %s: %v", out, err)
1743 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001744 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001745 cmd.Dir = workingDir
1746 if out, err := cmd.CombinedOutput(); err != nil {
1747 return fmt.Errorf("git fetch: %s: %w", out, err)
1748 }
1749 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1750 cmd.Dir = workingDir
1751 if out, err := cmd.CombinedOutput(); err != nil {
1752 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1753 }
1754 a.lastHEAD = revision
1755 a.initialCommit = revision
1756 return nil
1757}
1758
1759func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1760 a.mu.Lock()
1761 a.title = ""
1762 a.firstMessageIndex = len(a.history)
1763 a.convo = a.initConvo()
1764 gitReset := func() error {
1765 if a.config.InDocker && rev != "" {
1766 err := a.initGitRevision(ctx, a.workingDir, rev)
1767 if err != nil {
1768 return err
1769 }
1770 } else if !a.config.InDocker && rev != "" {
1771 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1772 }
1773 return nil
1774 }
1775 err := gitReset()
1776 a.mu.Unlock()
1777 if err != nil {
1778 a.pushToOutbox(a.config.Context, errorMessage(err))
1779 }
1780
1781 a.pushToOutbox(a.config.Context, AgentMessage{
1782 Type: AgentMessageType, Content: "Conversation restarted.",
1783 })
1784 if initialPrompt != "" {
1785 a.UserMessage(ctx, initialPrompt)
1786 }
1787 return nil
1788}
1789
1790func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1791 msg := `The user has requested a suggestion for a re-prompt.
1792
1793 Given the current conversation thus far, suggest a re-prompt that would
1794 capture the instructions and feedback so far, as well as any
1795 research or other information that would be helpful in implementing
1796 the task.
1797
1798 Reply with ONLY the reprompt text.
1799 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001800 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001801 // By doing this in a subconversation, the agent doesn't call tools (because
1802 // there aren't any), and there's not a concurrency risk with on-going other
1803 // outstanding conversations.
1804 convo := a.convo.SubConvoWithHistory()
1805 resp, err := convo.SendMessage(userMessage)
1806 if err != nil {
1807 a.pushToOutbox(ctx, errorMessage(err))
1808 return "", err
1809 }
1810 textContent := collectTextContent(resp)
1811 return textContent, nil
1812}
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001813
1814// systemPromptData contains the data used to render the system prompt template
1815type systemPromptData struct {
1816 EditPrompt string
1817 ClientGOOS string
1818 ClientGOARCH string
1819 WorkingDir string
1820 RepoRoot string
1821 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001822 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001823}
1824
1825// renderSystemPrompt renders the system prompt template.
1826func (a *Agent) renderSystemPrompt() string {
1827 // Determine the appropriate edit prompt based on config
1828 var editPrompt string
1829 if a.config.UseAnthropicEdit {
1830 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."
1831 } else {
1832 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1833 }
1834
1835 data := systemPromptData{
1836 EditPrompt: editPrompt,
1837 ClientGOOS: a.config.ClientGOOS,
1838 ClientGOARCH: a.config.ClientGOARCH,
1839 WorkingDir: a.workingDir,
1840 RepoRoot: a.repoRoot,
1841 InitialCommit: a.initialCommit,
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001842 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001843 }
1844
1845 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1846 if err != nil {
1847 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1848 }
1849 buf := new(strings.Builder)
1850 err = tmpl.Execute(buf, data)
1851 if err != nil {
1852 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1853 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001854 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001855 return buf.String()
1856}