blob: cc56f13758c8da9d6535a74a257453275f9d3458 [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"
Pokey Rule7a113622025-05-12 10:58:45 +010014 "path/filepath"
Earl Lee2e463fb2025-04-17 11:22:22 -070015 "runtime/debug"
16 "slices"
17 "strings"
18 "sync"
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +000019 "text/template"
Earl Lee2e463fb2025-04-17 11:22:22 -070020 "time"
21
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000022 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070023 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000024 "sketch.dev/claudetool/bashkit"
Autoformatter4962f152025-05-06 17:24:20 +000025 "sketch.dev/claudetool/browse"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000026 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydera997be62025-05-07 22:52:46 +000027 "sketch.dev/claudetool/onstart"
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -070028 "sketch.dev/experiment"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070029 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070030 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070031 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070032)
33
34const (
35 userCancelMessage = "user requested agent to stop handling responses"
36)
37
Philip Zeyligerb7c58752025-05-01 10:10:17 -070038type MessageIterator interface {
39 // Next blocks until the next message is available. It may
40 // return nil if the underlying iterator context is done.
41 Next() *AgentMessage
42 Close()
43}
44
Earl Lee2e463fb2025-04-17 11:22:22 -070045type CodingAgent interface {
46 // Init initializes an agent inside a docker container.
47 Init(AgentInit) error
48
49 // Ready returns a channel closed after Init successfully called.
50 Ready() <-chan struct{}
51
52 // URL reports the HTTP URL of this agent.
53 URL() string
54
55 // UserMessage enqueues a message to the agent and returns immediately.
56 UserMessage(ctx context.Context, msg string)
57
Philip Zeyligerb7c58752025-05-01 10:10:17 -070058 // Returns an iterator that finishes when the context is done and
59 // starts with the given message index.
60 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070061
62 // Loop begins the agent loop returns only when ctx is cancelled.
63 Loop(ctx context.Context)
64
Sean McCulloughedc88dc2025-04-30 02:55:01 +000065 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070066
67 CancelToolUse(toolUseID string, cause error) error
68
69 // Returns a subset of the agent's message history.
70 Messages(start int, end int) []AgentMessage
71
72 // Returns the current number of messages in the history
73 MessageCount() int
74
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070075 TotalUsage() conversation.CumulativeUsage
76 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070077
Earl Lee2e463fb2025-04-17 11:22:22 -070078 WorkingDir() string
79
80 // Diff returns a unified diff of changes made since the agent was instantiated.
81 // If commit is non-nil, it shows the diff for just that specific commit.
82 Diff(commit *string) (string, error)
83
84 // InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
85 InitialCommit() string
86
87 // Title returns the current title of the conversation.
88 Title() string
89
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000090 // BranchName returns the git branch name for the conversation.
91 BranchName() string
92
Earl Lee2e463fb2025-04-17 11:22:22 -070093 // OS returns the operating system of the client.
94 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +000095
Philip Zeyligerc72fff52025-04-29 20:17:54 +000096 // SessionID returns the unique session identifier.
97 SessionID() string
98
Philip Zeyliger99a9a022025-04-27 15:15:25 +000099 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
100 OutstandingLLMCallCount() int
101
102 // OutstandingToolCalls returns the names of outstanding tool calls.
103 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000104 OutsideOS() string
105 OutsideHostname() string
106 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000107 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000108 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
109 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700110
111 // RestartConversation resets the conversation history
112 RestartConversation(ctx context.Context, rev string, initialPrompt string) error
113 // SuggestReprompt suggests a re-prompt based on the current conversation.
114 SuggestReprompt(ctx context.Context) (string, error)
115 // IsInContainer returns true if the agent is running in a container
116 IsInContainer() bool
117 // FirstMessageIndex returns the index of the first message in the current conversation
118 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700119
120 CurrentStateName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700121}
122
123type CodingAgentMessageType string
124
125const (
126 UserMessageType CodingAgentMessageType = "user"
127 AgentMessageType CodingAgentMessageType = "agent"
128 ErrorMessageType CodingAgentMessageType = "error"
129 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
130 ToolUseMessageType CodingAgentMessageType = "tool"
131 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
132 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
133
134 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
135)
136
137type AgentMessage struct {
138 Type CodingAgentMessageType `json:"type"`
139 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
140 EndOfTurn bool `json:"end_of_turn"`
141
142 Content string `json:"content"`
143 ToolName string `json:"tool_name,omitempty"`
144 ToolInput string `json:"input,omitempty"`
145 ToolResult string `json:"tool_result,omitempty"`
146 ToolError bool `json:"tool_error,omitempty"`
147 ToolCallId string `json:"tool_call_id,omitempty"`
148
149 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
150 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
151
Sean McCulloughd9f13372025-04-21 15:08:49 -0700152 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
153 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
154
Earl Lee2e463fb2025-04-17 11:22:22 -0700155 // Commits is a list of git commits for a commit message
156 Commits []*GitCommit `json:"commits,omitempty"`
157
158 Timestamp time.Time `json:"timestamp"`
159 ConversationID string `json:"conversation_id"`
160 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700161 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700162
163 // Message timing information
164 StartTime *time.Time `json:"start_time,omitempty"`
165 EndTime *time.Time `json:"end_time,omitempty"`
166 Elapsed *time.Duration `json:"elapsed,omitempty"`
167
168 // Turn duration - the time taken for a complete agent turn
169 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
170
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000171 // HideOutput indicates that this message should not be rendered in the UI.
172 // This is useful for subconversations that generate output that shouldn't be shown to the user.
173 HideOutput bool `json:"hide_output,omitempty"`
174
Earl Lee2e463fb2025-04-17 11:22:22 -0700175 Idx int `json:"idx"`
176}
177
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000178// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700179func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700180 if convo == nil {
181 m.ConversationID = ""
182 m.ParentConversationID = nil
183 return
184 }
185 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000186 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700187 if convo.Parent != nil {
188 m.ParentConversationID = &convo.Parent.ID
189 }
190}
191
Earl Lee2e463fb2025-04-17 11:22:22 -0700192// GitCommit represents a single git commit for a commit message
193type GitCommit struct {
194 Hash string `json:"hash"` // Full commit hash
195 Subject string `json:"subject"` // Commit subject line
196 Body string `json:"body"` // Full commit message body
197 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
198}
199
200// ToolCall represents a single tool call within an agent message
201type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700202 Name string `json:"name"`
203 Input string `json:"input"`
204 ToolCallId string `json:"tool_call_id"`
205 ResultMessage *AgentMessage `json:"result_message,omitempty"`
206 Args string `json:"args,omitempty"`
207 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700208}
209
210func (a *AgentMessage) Attr() slog.Attr {
211 var attrs []any = []any{
212 slog.String("type", string(a.Type)),
213 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700214 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700215 if a.EndOfTurn {
216 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
217 }
218 if a.Content != "" {
219 attrs = append(attrs, slog.String("content", a.Content))
220 }
221 if a.ToolName != "" {
222 attrs = append(attrs, slog.String("tool_name", a.ToolName))
223 }
224 if a.ToolInput != "" {
225 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
226 }
227 if a.Elapsed != nil {
228 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
229 }
230 if a.TurnDuration != nil {
231 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
232 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700233 if len(a.ToolResult) > 0 {
234 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700235 }
236 if a.ToolError {
237 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
238 }
239 if len(a.ToolCalls) > 0 {
240 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
241 for i, tc := range a.ToolCalls {
242 toolCallAttrs = append(toolCallAttrs, slog.Group(
243 fmt.Sprintf("tool_call_%d", i),
244 slog.String("name", tc.Name),
245 slog.String("input", tc.Input),
246 ))
247 }
248 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
249 }
250 if a.ConversationID != "" {
251 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
252 }
253 if a.ParentConversationID != nil {
254 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
255 }
256 if a.Usage != nil && !a.Usage.IsZero() {
257 attrs = append(attrs, a.Usage.Attr())
258 }
259 // TODO: timestamp, convo ids, idx?
260 return slog.Group("agent_message", attrs...)
261}
262
263func errorMessage(err error) AgentMessage {
264 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
265 if os.Getenv(("DEBUG")) == "1" {
266 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
267 }
268
269 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
270}
271
272func budgetMessage(err error) AgentMessage {
273 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
274}
275
276// ConvoInterface defines the interface for conversation interactions
277type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700278 CumulativeUsage() conversation.CumulativeUsage
279 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700280 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700281 SendMessage(message llm.Message) (*llm.Response, error)
282 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700283 GetID() string
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700284 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error)
285 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700286 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700287 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700288}
289
290type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700291 convo ConvoInterface
292 config AgentConfig // config for this agent
293 workingDir string
294 repoRoot string // workingDir may be a subdir of repoRoot
295 url string
296 firstMessageIndex int // index of the first message in the current conversation
297 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
298 initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
299 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000300 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700301 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000302 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700303 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700304 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700305 title string
306 branchName string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000307 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700308 // State machine to track agent state
309 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000310 // Outside information
311 outsideHostname string
312 outsideOS string
313 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000314 // URL of the git remote 'origin' if it exists
315 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700316
317 // Time when the current turn started (reset at the beginning of InnerLoop)
318 startOfTurn time.Time
319
320 // Inbox - for messages from the user to the agent.
321 // sent on by UserMessage
322 // . e.g. when user types into the chat textarea
323 // read from by GatherMessages
324 inbox chan string
325
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000326 // protects cancelTurn
327 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700328 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000329 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700330
331 // protects following
332 mu sync.Mutex
333
334 // Stores all messages for this agent
335 history []AgentMessage
336
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700337 // Iterators add themselves here when they're ready to be notified of new messages.
338 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700339
340 // Track git commits we've already seen (by hash)
341 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000342
343 // Track outstanding LLM call IDs
344 outstandingLLMCalls map[string]struct{}
345
346 // Track outstanding tool calls by ID with their names
347 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700348}
349
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700350// NewIterator implements CodingAgent.
351func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
352 a.mu.Lock()
353 defer a.mu.Unlock()
354
355 return &MessageIteratorImpl{
356 agent: a,
357 ctx: ctx,
358 nextMessageIdx: nextMessageIdx,
359 ch: make(chan *AgentMessage, 100),
360 }
361}
362
363type MessageIteratorImpl struct {
364 agent *Agent
365 ctx context.Context
366 nextMessageIdx int
367 ch chan *AgentMessage
368 subscribed bool
369}
370
371func (m *MessageIteratorImpl) Close() {
372 m.agent.mu.Lock()
373 defer m.agent.mu.Unlock()
374 // Delete ourselves from the subscribers list
375 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
376 return x == m.ch
377 })
378 close(m.ch)
379}
380
381func (m *MessageIteratorImpl) Next() *AgentMessage {
382 // We avoid subscription at creation to let ourselves catch up to "current state"
383 // before subscribing.
384 if !m.subscribed {
385 m.agent.mu.Lock()
386 if m.nextMessageIdx < len(m.agent.history) {
387 msg := &m.agent.history[m.nextMessageIdx]
388 m.nextMessageIdx++
389 m.agent.mu.Unlock()
390 return msg
391 }
392 // The next message doesn't exist yet, so let's subscribe
393 m.agent.subscribers = append(m.agent.subscribers, m.ch)
394 m.subscribed = true
395 m.agent.mu.Unlock()
396 }
397
398 for {
399 select {
400 case <-m.ctx.Done():
401 m.agent.mu.Lock()
402 // Delete ourselves from the subscribers list
403 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
404 return x == m.ch
405 })
406 m.subscribed = false
407 m.agent.mu.Unlock()
408 return nil
409 case msg, ok := <-m.ch:
410 if !ok {
411 // Close may have been called
412 return nil
413 }
414 if msg.Idx == m.nextMessageIdx {
415 m.nextMessageIdx++
416 return msg
417 }
418 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
419 panic("out of order message")
420 }
421 }
422}
423
Sean McCulloughd9d45812025-04-30 16:53:41 -0700424// Assert that Agent satisfies the CodingAgent interface.
425var _ CodingAgent = &Agent{}
426
427// StateName implements CodingAgent.
428func (a *Agent) CurrentStateName() string {
429 if a.stateMachine == nil {
430 return ""
431 }
432 return a.stateMachine.currentState.String()
433}
434
Earl Lee2e463fb2025-04-17 11:22:22 -0700435func (a *Agent) URL() string { return a.url }
436
437// Title returns the current title of the conversation.
438// If no title has been set, returns an empty string.
439func (a *Agent) Title() string {
440 a.mu.Lock()
441 defer a.mu.Unlock()
442 return a.title
443}
444
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000445// BranchName returns the git branch name for the conversation.
446func (a *Agent) BranchName() string {
447 a.mu.Lock()
448 defer a.mu.Unlock()
449 return a.branchName
450}
451
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000452// OutstandingLLMCallCount returns the number of outstanding LLM calls.
453func (a *Agent) OutstandingLLMCallCount() int {
454 a.mu.Lock()
455 defer a.mu.Unlock()
456 return len(a.outstandingLLMCalls)
457}
458
459// OutstandingToolCalls returns the names of outstanding tool calls.
460func (a *Agent) OutstandingToolCalls() []string {
461 a.mu.Lock()
462 defer a.mu.Unlock()
463
464 tools := make([]string, 0, len(a.outstandingToolCalls))
465 for _, toolName := range a.outstandingToolCalls {
466 tools = append(tools, toolName)
467 }
468 return tools
469}
470
Earl Lee2e463fb2025-04-17 11:22:22 -0700471// OS returns the operating system of the client.
472func (a *Agent) OS() string {
473 return a.config.ClientGOOS
474}
475
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000476func (a *Agent) SessionID() string {
477 return a.config.SessionID
478}
479
Philip Zeyliger18532b22025-04-23 21:11:46 +0000480// OutsideOS returns the operating system of the outside system.
481func (a *Agent) OutsideOS() string {
482 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000483}
484
Philip Zeyliger18532b22025-04-23 21:11:46 +0000485// OutsideHostname returns the hostname of the outside system.
486func (a *Agent) OutsideHostname() string {
487 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000488}
489
Philip Zeyliger18532b22025-04-23 21:11:46 +0000490// OutsideWorkingDir returns the working directory on the outside system.
491func (a *Agent) OutsideWorkingDir() string {
492 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000493}
494
495// GitOrigin returns the URL of the git remote 'origin' if it exists.
496func (a *Agent) GitOrigin() string {
497 return a.gitOrigin
498}
499
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000500func (a *Agent) OpenBrowser(url string) {
501 if !a.IsInContainer() {
502 browser.Open(url)
503 return
504 }
505 // We're in Docker, need to send a request to the Git server
506 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700507 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000508 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700509 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000510 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700511 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000512 return
513 }
514 defer resp.Body.Close()
515 if resp.StatusCode == http.StatusOK {
516 return
517 }
518 body, _ := io.ReadAll(resp.Body)
519 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
520}
521
Sean McCullough96b60dd2025-04-30 09:49:10 -0700522// CurrentState returns the current state of the agent's state machine.
523func (a *Agent) CurrentState() State {
524 return a.stateMachine.CurrentState()
525}
526
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700527func (a *Agent) IsInContainer() bool {
528 return a.config.InDocker
529}
530
531func (a *Agent) FirstMessageIndex() int {
532 a.mu.Lock()
533 defer a.mu.Unlock()
534 return a.firstMessageIndex
535}
536
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000537// SetTitle sets the title of the conversation.
538func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700539 a.mu.Lock()
540 defer a.mu.Unlock()
541 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000542}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700543
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000544// SetBranch sets the branch name of the conversation.
545func (a *Agent) SetBranch(branchName string) {
546 a.mu.Lock()
547 defer a.mu.Unlock()
548 a.branchName = branchName
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000549 convo, ok := a.convo.(*conversation.Convo)
550 if ok {
551 convo.ExtraData["branch"] = branchName
552 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700553}
554
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000555// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700556func (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 +0000557 // Track the tool call
558 a.mu.Lock()
559 a.outstandingToolCalls[id] = toolName
560 a.mu.Unlock()
561}
562
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700563// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
564// If there's only one element in the array and it's a text type, it returns that text directly.
565// It also processes nested ToolResult arrays recursively.
566func contentToString(contents []llm.Content) string {
567 if len(contents) == 0 {
568 return ""
569 }
570
571 // If there's only one element and it's a text type, return it directly
572 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
573 return contents[0].Text
574 }
575
576 // Otherwise, concatenate all text content
577 var result strings.Builder
578 for _, content := range contents {
579 if content.Type == llm.ContentTypeText {
580 result.WriteString(content.Text)
581 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
582 // Recursively process nested tool results
583 result.WriteString(contentToString(content.ToolResult))
584 }
585 }
586
587 return result.String()
588}
589
Earl Lee2e463fb2025-04-17 11:22:22 -0700590// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700591func (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 +0000592 // Remove the tool call from outstanding calls
593 a.mu.Lock()
594 delete(a.outstandingToolCalls, toolID)
595 a.mu.Unlock()
596
Earl Lee2e463fb2025-04-17 11:22:22 -0700597 m := AgentMessage{
598 Type: ToolUseMessageType,
599 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700600 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700601 ToolError: content.ToolError,
602 ToolName: toolName,
603 ToolInput: string(toolInput),
604 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700605 StartTime: content.ToolUseStartTime,
606 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700607 }
608
609 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700610 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
611 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700612 m.Elapsed = &elapsed
613 }
614
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700615 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700616 a.pushToOutbox(ctx, m)
617}
618
619// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700620func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000621 a.mu.Lock()
622 defer a.mu.Unlock()
623 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700624 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
625}
626
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700627// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700628// that need to be displayed (as well as tool calls that we send along when
629// they're done). (It would be reasonable to also mention tool calls when they're
630// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700631func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000632 // Remove the LLM call from outstanding calls
633 a.mu.Lock()
634 delete(a.outstandingLLMCalls, id)
635 a.mu.Unlock()
636
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700637 if resp == nil {
638 // LLM API call failed
639 m := AgentMessage{
640 Type: ErrorMessageType,
641 Content: "API call failed, type 'continue' to try again",
642 }
643 m.SetConvo(convo)
644 a.pushToOutbox(ctx, m)
645 return
646 }
647
Earl Lee2e463fb2025-04-17 11:22:22 -0700648 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700649 if convo.Parent == nil { // subconvos never end the turn
650 switch resp.StopReason {
651 case llm.StopReasonToolUse:
652 // Check whether any of the tool calls are for tools that should end the turn
653 ToolSearch:
654 for _, part := range resp.Content {
655 if part.Type != llm.ContentTypeToolUse {
656 continue
657 }
Sean McCullough021557a2025-05-05 23:20:53 +0000658 // Find the tool by name
659 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700660 if tool.Name == part.ToolName {
661 endOfTurn = tool.EndsTurn
662 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000663 }
664 }
Sean McCullough021557a2025-05-05 23:20:53 +0000665 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700666 default:
667 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000668 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700669 }
670 m := AgentMessage{
671 Type: AgentMessageType,
672 Content: collectTextContent(resp),
673 EndOfTurn: endOfTurn,
674 Usage: &resp.Usage,
675 StartTime: resp.StartTime,
676 EndTime: resp.EndTime,
677 }
678
679 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700680 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700681 var toolCalls []ToolCall
682 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700683 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700684 toolCalls = append(toolCalls, ToolCall{
685 Name: part.ToolName,
686 Input: string(part.ToolInput),
687 ToolCallId: part.ID,
688 })
689 }
690 }
691 m.ToolCalls = toolCalls
692 }
693
694 // Calculate the elapsed time if both start and end times are set
695 if resp.StartTime != nil && resp.EndTime != nil {
696 elapsed := resp.EndTime.Sub(*resp.StartTime)
697 m.Elapsed = &elapsed
698 }
699
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700700 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700701 a.pushToOutbox(ctx, m)
702}
703
704// WorkingDir implements CodingAgent.
705func (a *Agent) WorkingDir() string {
706 return a.workingDir
707}
708
709// MessageCount implements CodingAgent.
710func (a *Agent) MessageCount() int {
711 a.mu.Lock()
712 defer a.mu.Unlock()
713 return len(a.history)
714}
715
716// Messages implements CodingAgent.
717func (a *Agent) Messages(start int, end int) []AgentMessage {
718 a.mu.Lock()
719 defer a.mu.Unlock()
720 return slices.Clone(a.history[start:end])
721}
722
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700723func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700724 return a.originalBudget
725}
726
727// AgentConfig contains configuration for creating a new Agent.
728type AgentConfig struct {
729 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700730 Service llm.Service
731 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700732 GitUsername string
733 GitEmail string
734 SessionID string
735 ClientGOOS string
736 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700737 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700738 UseAnthropicEdit bool
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000739 OneShot bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000740 // Outside information
741 OutsideHostname string
742 OutsideOS string
743 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700744}
745
746// NewAgent creates a new Agent.
747// It is not usable until Init() is called.
748func NewAgent(config AgentConfig) *Agent {
749 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000750 config: config,
751 ready: make(chan struct{}),
752 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700753 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000754 startedAt: time.Now(),
755 originalBudget: config.Budget,
756 seenCommits: make(map[string]bool),
757 outsideHostname: config.OutsideHostname,
758 outsideOS: config.OutsideOS,
759 outsideWorkingDir: config.OutsideWorkingDir,
760 outstandingLLMCalls: make(map[string]struct{}),
761 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700762 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700763 }
764 return agent
765}
766
767type AgentInit struct {
768 WorkingDir string
769 NoGit bool // only for testing
770
771 InDocker bool
772 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000773 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700774 GitRemoteAddr string
775 HostAddr string
776}
777
778func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700779 if a.convo != nil {
780 return fmt.Errorf("Agent.Init: already initialized")
781 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700782 ctx := a.config.Context
783 if ini.InDocker {
784 cmd := exec.CommandContext(ctx, "git", "stash")
785 cmd.Dir = ini.WorkingDir
786 if out, err := cmd.CombinedOutput(); err != nil {
787 return fmt.Errorf("git stash: %s: %v", out, err)
788 }
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700789 // sketch-host is a git repo hosted by "outtie sketch". When it notices a 'git fetch',
790 // it runs "git fetch" underneath the covers to get its latest commits. By configuring
791 // an additional remote.sketch-host.fetch, we make "origin/main" on innie sketch look like
792 // origin/main on outtie sketch, which should make it easier to rebase.
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700793 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
794 cmd.Dir = ini.WorkingDir
795 if out, err := cmd.CombinedOutput(); err != nil {
796 return fmt.Errorf("git remote add: %s: %v", out, err)
797 }
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700798 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.sketch-host.fetch",
799 "+refs/heads/feature/*:refs/remotes/origin/feature/*")
800 cmd.Dir = ini.WorkingDir
801 if out, err := cmd.CombinedOutput(); err != nil {
802 return fmt.Errorf("git config --add: %s: %v", out, err)
803 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000804 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700805 cmd.Dir = ini.WorkingDir
806 if out, err := cmd.CombinedOutput(); err != nil {
807 return fmt.Errorf("git fetch: %s: %w", out, err)
808 }
809 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
810 cmd.Dir = ini.WorkingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100811 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
812 // Remove git hooks if they exist and retry
813 // Only try removing hooks if we haven't already removed them during fetch
814 hookPath := filepath.Join(ini.WorkingDir, ".git", "hooks")
815 if _, statErr := os.Stat(hookPath); statErr == nil {
816 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
817 slog.String("error", err.Error()),
818 slog.String("output", string(checkoutOut)))
819 if removeErr := removeGitHooks(ctx, ini.WorkingDir); removeErr != nil {
820 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
821 }
822
823 // Retry the checkout operation
824 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
825 cmd.Dir = ini.WorkingDir
826 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
827 return fmt.Errorf("git checkout %s failed even after removing hooks: %s: %w", ini.Commit, retryOut, retryErr)
828 }
829 } else {
830 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, checkoutOut, err)
831 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700832 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700833 a.lastHEAD = ini.Commit
834 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000835 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700836 a.initialCommit = ini.Commit
837 if ini.HostAddr != "" {
838 a.url = "http://" + ini.HostAddr
839 }
840 }
841 a.workingDir = ini.WorkingDir
842
843 if !ini.NoGit {
844 repoRoot, err := repoRoot(ctx, a.workingDir)
845 if err != nil {
846 return fmt.Errorf("repoRoot: %w", err)
847 }
848 a.repoRoot = repoRoot
849
850 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
851 if err != nil {
852 return fmt.Errorf("resolveRef: %w", err)
853 }
854 a.initialCommit = commitHash
855
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000856 if experiment.Enabled("memory") {
857 slog.Info("running codebase analysis")
858 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
859 if err != nil {
860 slog.Warn("failed to analyze codebase", "error", err)
861 }
862 a.codebase = codebase
863 }
864
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000865 llmCodeReview := codereview.NoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700866 if experiment.Enabled("llm_review") {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000867 llmCodeReview = codereview.DoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700868 }
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000869 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit, llmCodeReview)
Earl Lee2e463fb2025-04-17 11:22:22 -0700870 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000871 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700872 }
873 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000874
875 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700876 }
877 a.lastHEAD = a.initialCommit
878 a.convo = a.initConvo()
879 close(a.ready)
880 return nil
881}
882
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700883//go:embed agent_system_prompt.txt
884var agentSystemPrompt string
885
Earl Lee2e463fb2025-04-17 11:22:22 -0700886// initConvo initializes the conversation.
887// It must not be called until all agent fields are initialized,
888// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700889func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700890 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700891 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700892 convo.PromptCaching = true
893 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000894 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000895 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -0700896
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000897 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
898 bashPermissionCheck := func(command string) error {
899 // Check if branch name is set
900 a.mu.Lock()
901 branchSet := a.branchName != ""
902 a.mu.Unlock()
903
904 // If branch is set, all commands are allowed
905 if branchSet {
906 return nil
907 }
908
909 // If branch is not set, check if this is a git commit command
910 willCommit, err := bashkit.WillRunGitCommit(command)
911 if err != nil {
912 // If there's an error checking, we should allow the command to proceed
913 return nil
914 }
915
916 // If it's a git commit and branch is not set, return an error
917 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000918 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000919 }
920
921 return nil
922 }
923
924 // Create a custom bash tool with the permission check
925 bashTool := claudetool.NewBashTool(bashPermissionCheck)
926
Earl Lee2e463fb2025-04-17 11:22:22 -0700927 // Register all tools with the conversation
928 // When adding, removing, or modifying tools here, double-check that the termui tool display
929 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000930
931 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -0700932 _, supportsScreenshots := a.config.Service.(*ant.Service)
933 var bTools []*llm.Tool
934 var browserCleanup func()
935
936 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
937 // Add cleanup function to context cancel
938 go func() {
939 <-a.config.Context.Done()
940 browserCleanup()
941 }()
942 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000943
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700944 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000945 bashTool, claudetool.Keyword,
Josh Bleecher Snyder93202652025-05-08 02:05:57 +0000946 claudetool.Think, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000947 a.codereview.Tool(),
948 }
949
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000950 if experiment.Enabled("kb") {
951 convo.Tools = append(convo.Tools, claudetool.KnowledgeBase)
952 }
953
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000954 // One-shot mode is non-interactive, multiple choice requires human response
955 if !a.config.OneShot {
956 convo.Tools = append(convo.Tools, a.multipleChoiceTool())
Earl Lee2e463fb2025-04-17 11:22:22 -0700957 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000958
959 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -0700960 if a.config.UseAnthropicEdit {
961 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
962 } else {
963 convo.Tools = append(convo.Tools, claudetool.Patch)
964 }
965 convo.Listener = a
966 return convo
967}
968
Sean McCullough485afc62025-04-28 14:28:39 -0700969func (a *Agent) multipleChoiceTool() *llm.Tool {
970 ret := &llm.Tool{
971 Name: "multiplechoice",
972 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 +0000973 EndsTurn: true,
Sean McCullough485afc62025-04-28 14:28:39 -0700974 InputSchema: json.RawMessage(`{
975 "type": "object",
976 "description": "The question and a list of answers you would expect the user to choose from.",
977 "properties": {
978 "question": {
979 "type": "string",
980 "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?'"
981 },
982 "responseOptions": {
983 "type": "array",
984 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
985 "items": {
986 "type": "object",
987 "properties": {
988 "caption": {
989 "type": "string",
990 "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'"
991 },
992 "responseText": {
993 "type": "string",
994 "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'"
995 }
996 },
997 "required": ["caption", "responseText"]
998 }
999 }
1000 },
1001 "required": ["question", "responseOptions"]
1002}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001003 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Sean McCullough485afc62025-04-28 14:28:39 -07001004 // The Run logic for "multiplchoice" tool is a no-op on the server.
1005 // The UI will present a list of options for the user to select from,
1006 // and that's it as far as "executing" the tool_use goes.
1007 // When the user *does* select one of the presented options, that
1008 // responseText gets sent as a chat message on behalf of the user.
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001009 return llm.TextContent("end your turn and wait for the user to respond"), nil
Sean McCullough485afc62025-04-28 14:28:39 -07001010 },
1011 }
1012 return ret
1013}
1014
1015type MultipleChoiceOption struct {
1016 Caption string `json:"caption"`
1017 ResponseText string `json:"responseText"`
1018}
1019
1020type MultipleChoiceParams struct {
1021 Question string `json:"question"`
1022 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1023}
1024
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001025// branchExists reports whether branchName exists, either locally or in well-known remotes.
1026func branchExists(dir, branchName string) bool {
1027 refs := []string{
1028 "refs/heads/",
1029 "refs/remotes/origin/",
1030 "refs/remotes/sketch-host/",
1031 }
1032 for _, ref := range refs {
1033 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1034 cmd.Dir = dir
1035 if cmd.Run() == nil { // exit code 0 means branch exists
1036 return true
1037 }
1038 }
1039 return false
1040}
1041
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001042func (a *Agent) titleTool() *llm.Tool {
1043 description := `Sets the conversation title.`
1044 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001045 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001046 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001047 InputSchema: json.RawMessage(`{
1048 "type": "object",
1049 "properties": {
1050 "title": {
1051 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001052 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001053 }
1054 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001055 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001056}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001057 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001058 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001059 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001060 }
1061 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001062 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001063 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001064
1065 // We don't allow changing the title once set to be consistent with the previous behavior
1066 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001067 t := a.Title()
1068 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001069 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001070 }
1071
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001072 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001073 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001074 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001075
1076 a.SetTitle(params.Title)
1077 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001078 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001079 },
1080 }
1081 return titleTool
1082}
1083
1084func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001085 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 +00001086 preCommit := &llm.Tool{
1087 Name: "precommit",
1088 Description: description,
1089 InputSchema: json.RawMessage(`{
1090 "type": "object",
1091 "properties": {
1092 "branch_name": {
1093 "type": "string",
1094 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1095 }
1096 },
1097 "required": ["branch_name"]
1098}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001099 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001100 var params struct {
1101 BranchName string `json:"branch_name"`
1102 }
1103 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001104 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001105 }
1106
1107 b := a.BranchName()
1108 if b != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001109 return nil, fmt.Errorf("branch already set to: %s", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001110 }
1111
1112 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001113 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001114 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001115 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001116 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001117 }
1118 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001119 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001120 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001121 }
1122
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001123 a.SetBranch(branchName)
1124 response := fmt.Sprintf("Branch name set to %q", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001125
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001126 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1127 if err != nil {
1128 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1129 }
1130 if len(styleHint) > 0 {
1131 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001132 }
1133
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001134 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001135 },
1136 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001137 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001138}
1139
1140func (a *Agent) Ready() <-chan struct{} {
1141 return a.ready
1142}
1143
1144func (a *Agent) UserMessage(ctx context.Context, msg string) {
1145 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1146 a.inbox <- msg
1147}
1148
Earl Lee2e463fb2025-04-17 11:22:22 -07001149func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1150 return a.convo.CancelToolUse(toolUseID, cause)
1151}
1152
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001153func (a *Agent) CancelTurn(cause error) {
1154 a.cancelTurnMu.Lock()
1155 defer a.cancelTurnMu.Unlock()
1156 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001157 // Force state transition to cancelled state
1158 ctx := a.config.Context
1159 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001160 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001161 }
1162}
1163
1164func (a *Agent) Loop(ctxOuter context.Context) {
1165 for {
1166 select {
1167 case <-ctxOuter.Done():
1168 return
1169 default:
1170 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001171 a.cancelTurnMu.Lock()
1172 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001173 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001174 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001175 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001176 a.cancelTurn = cancel
1177 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001178 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1179 if err != nil {
1180 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1181 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001182 cancel(nil)
1183 }
1184 }
1185}
1186
1187func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1188 if m.Timestamp.IsZero() {
1189 m.Timestamp = time.Now()
1190 }
1191
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001192 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1193 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1194 m.Content = m.ToolResult
1195 }
1196
Earl Lee2e463fb2025-04-17 11:22:22 -07001197 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1198 if m.EndOfTurn && m.Type == AgentMessageType {
1199 turnDuration := time.Since(a.startOfTurn)
1200 m.TurnDuration = &turnDuration
1201 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1202 }
1203
Earl Lee2e463fb2025-04-17 11:22:22 -07001204 a.mu.Lock()
1205 defer a.mu.Unlock()
1206 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001207 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001208 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001209
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001210 // Notify all subscribers
1211 for _, ch := range a.subscribers {
1212 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001213 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001214}
1215
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001216func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1217 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001218 if block {
1219 select {
1220 case <-ctx.Done():
1221 return m, ctx.Err()
1222 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001223 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001224 }
1225 }
1226 for {
1227 select {
1228 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001229 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001230 default:
1231 return m, nil
1232 }
1233 }
1234}
1235
Sean McCullough885a16a2025-04-30 02:49:25 +00001236// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001237func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001238 // Reset the start of turn time
1239 a.startOfTurn = time.Now()
1240
Sean McCullough96b60dd2025-04-30 09:49:10 -07001241 // Transition to waiting for user input state
1242 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1243
Sean McCullough885a16a2025-04-30 02:49:25 +00001244 // Process initial user message
1245 initialResp, err := a.processUserMessage(ctx)
1246 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001247 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001248 return err
1249 }
1250
1251 // Handle edge case where both initialResp and err are nil
1252 if initialResp == nil {
1253 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001254 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1255
Sean McCullough9f4b8082025-04-30 17:34:07 +00001256 a.pushToOutbox(ctx, errorMessage(err))
1257 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001258 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001259
Earl Lee2e463fb2025-04-17 11:22:22 -07001260 // We do this as we go, but let's also do it at the end of the turn
1261 defer func() {
1262 if _, err := a.handleGitCommits(ctx); err != nil {
1263 // Just log the error, don't stop execution
1264 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1265 }
1266 }()
1267
Sean McCullougha1e0e492025-05-01 10:51:08 -07001268 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001269 resp := initialResp
1270 for {
1271 // Check if we are over budget
1272 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001273 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001274 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001275 }
1276
1277 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001278 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001279 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001280 break
1281 }
1282
Sean McCullough96b60dd2025-04-30 09:49:10 -07001283 // Transition to tool use requested state
1284 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1285
Sean McCullough885a16a2025-04-30 02:49:25 +00001286 // Handle tool execution
1287 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1288 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001289 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001290 }
1291
Sean McCullougha1e0e492025-05-01 10:51:08 -07001292 if toolResp == nil {
1293 return fmt.Errorf("cannot continue conversation with a nil tool response")
1294 }
1295
Sean McCullough885a16a2025-04-30 02:49:25 +00001296 // Set the response for the next iteration
1297 resp = toolResp
1298 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001299
1300 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001301}
1302
1303// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001304func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001305 // Wait for at least one message from the user
1306 msgs, err := a.GatherMessages(ctx, true)
1307 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001308 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001309 return nil, err
1310 }
1311
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001312 userMessage := llm.Message{
1313 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001314 Content: msgs,
1315 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001316
Sean McCullough96b60dd2025-04-30 09:49:10 -07001317 // Transition to sending to LLM state
1318 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1319
Sean McCullough885a16a2025-04-30 02:49:25 +00001320 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001321 resp, err := a.convo.SendMessage(userMessage)
1322 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001323 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001324 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001325 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001326 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001327
Sean McCullough96b60dd2025-04-30 09:49:10 -07001328 // Transition to processing LLM response state
1329 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1330
Sean McCullough885a16a2025-04-30 02:49:25 +00001331 return resp, nil
1332}
1333
1334// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001335func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1336 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001337 cancelled := false
1338
Sean McCullough96b60dd2025-04-30 09:49:10 -07001339 // Transition to checking for cancellation state
1340 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1341
Sean McCullough885a16a2025-04-30 02:49:25 +00001342 // Check if the operation was cancelled by the user
1343 select {
1344 case <-ctx.Done():
1345 // Don't actually run any of the tools, but rather build a response
1346 // for each tool_use message letting the LLM know that user canceled it.
1347 var err error
1348 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001349 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001350 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001351 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001352 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001353 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001354 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001355 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001356 // Transition to running tool state
1357 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1358
Sean McCullough885a16a2025-04-30 02:49:25 +00001359 // Add working directory to context for tool execution
1360 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1361
1362 // Execute the tools
1363 var err error
1364 results, err = a.convo.ToolResultContents(ctx, resp)
1365 if ctx.Err() != nil { // e.g. the user canceled the operation
1366 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001367 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001368 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001369 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001370 a.pushToOutbox(ctx, errorMessage(err))
1371 }
1372 }
1373
1374 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001375 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001376 autoqualityMessages := a.processGitChanges(ctx)
1377
1378 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001379 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001380 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001381 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001382 return false, nil
1383 }
1384
1385 // Continue the conversation with tool results and any user messages
1386 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1387}
1388
1389// processGitChanges checks for new git commits and runs autoformatters if needed
1390func (a *Agent) processGitChanges(ctx context.Context) []string {
1391 // Check for git commits after tool execution
1392 newCommits, err := a.handleGitCommits(ctx)
1393 if err != nil {
1394 // Just log the error, don't stop execution
1395 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1396 return nil
1397 }
1398
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001399 // Run mechanical checks if there was exactly one new commit.
1400 if len(newCommits) != 1 {
1401 return nil
1402 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001403 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001404 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1405 msg := a.codereview.RunMechanicalChecks(ctx)
1406 if msg != "" {
1407 a.pushToOutbox(ctx, AgentMessage{
1408 Type: AutoMessageType,
1409 Content: msg,
1410 Timestamp: time.Now(),
1411 })
1412 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001413 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001414
1415 return autoqualityMessages
1416}
1417
1418// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001419func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001420 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001421 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001422 msgs, err := a.GatherMessages(ctx, false)
1423 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001424 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001425 return false, nil
1426 }
1427
1428 // Inject any auto-generated messages from quality checks
1429 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001430 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001431 }
1432
1433 // Handle cancellation by appending a message about it
1434 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001435 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001436 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001437 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001438 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1439 } else if err := a.convo.OverBudget(); err != nil {
1440 // Handle budget issues by appending a message about it
1441 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 -07001442 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001443 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1444 }
1445
1446 // Combine tool results with user messages
1447 results = append(results, msgs...)
1448
1449 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001450 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001451 resp, err := a.convo.SendMessage(llm.Message{
1452 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001453 Content: results,
1454 })
1455 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001456 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001457 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1458 return true, nil // Return true to continue the conversation, but with no response
1459 }
1460
Sean McCullough96b60dd2025-04-30 09:49:10 -07001461 // Transition back to processing LLM response
1462 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1463
Sean McCullough885a16a2025-04-30 02:49:25 +00001464 if cancelled {
1465 return false, nil
1466 }
1467
1468 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001469}
1470
1471func (a *Agent) overBudget(ctx context.Context) error {
1472 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001473 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001474 m := budgetMessage(err)
1475 m.Content = m.Content + "\n\nBudget reset."
1476 a.pushToOutbox(ctx, budgetMessage(err))
1477 a.convo.ResetBudget(a.originalBudget)
1478 return err
1479 }
1480 return nil
1481}
1482
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001483func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001484 // Collect all text content
1485 var allText strings.Builder
1486 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001487 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001488 if allText.Len() > 0 {
1489 allText.WriteString("\n\n")
1490 }
1491 allText.WriteString(content.Text)
1492 }
1493 }
1494 return allText.String()
1495}
1496
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001497func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001498 a.mu.Lock()
1499 defer a.mu.Unlock()
1500 return a.convo.CumulativeUsage()
1501}
1502
Earl Lee2e463fb2025-04-17 11:22:22 -07001503// Diff returns a unified diff of changes made since the agent was instantiated.
1504func (a *Agent) Diff(commit *string) (string, error) {
1505 if a.initialCommit == "" {
1506 return "", fmt.Errorf("no initial commit reference available")
1507 }
1508
1509 // Find the repository root
1510 ctx := context.Background()
1511
1512 // If a specific commit hash is provided, show just that commit's changes
1513 if commit != nil && *commit != "" {
1514 // Validate that the commit looks like a valid git SHA
1515 if !isValidGitSHA(*commit) {
1516 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1517 }
1518
1519 // Get the diff for just this commit
1520 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1521 cmd.Dir = a.repoRoot
1522 output, err := cmd.CombinedOutput()
1523 if err != nil {
1524 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1525 }
1526 return string(output), nil
1527 }
1528
1529 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1530 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1531 cmd.Dir = a.repoRoot
1532 output, err := cmd.CombinedOutput()
1533 if err != nil {
1534 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1535 }
1536
1537 return string(output), nil
1538}
1539
1540// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1541func (a *Agent) InitialCommit() string {
1542 return a.initialCommit
1543}
1544
Pokey Rule7a113622025-05-12 10:58:45 +01001545// removeGitHooks removes the Git hooks directory from the repository
1546func removeGitHooks(_ context.Context, repoPath string) error {
1547 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1548
1549 // Check if hooks directory exists
1550 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1551 // Directory doesn't exist, nothing to do
1552 return nil
1553 }
1554
1555 // Remove the hooks directory
1556 err := os.RemoveAll(hooksDir)
1557 if err != nil {
1558 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1559 }
1560
1561 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001562 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001563 if err != nil {
1564 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1565 }
1566
1567 return nil
1568}
1569
Earl Lee2e463fb2025-04-17 11:22:22 -07001570// handleGitCommits() highlights new commits to the user. When running
1571// under docker, new HEADs are pushed to a branch according to the title.
1572func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1573 if a.repoRoot == "" {
1574 return nil, nil
1575 }
1576
1577 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1578 if err != nil {
1579 return nil, err
1580 }
1581 if head == a.lastHEAD {
1582 return nil, nil // nothing to do
1583 }
1584 defer func() {
1585 a.lastHEAD = head
1586 }()
1587
1588 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1589 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1590 // to the last 100 commits.
1591 var commits []*GitCommit
1592
1593 // Get commits since the initial commit
1594 // Format: <hash>\0<subject>\0<body>\0
1595 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1596 // Limit to 100 commits to avoid overwhelming the user
1597 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1598 cmd.Dir = a.repoRoot
1599 output, err := cmd.Output()
1600 if err != nil {
1601 return nil, fmt.Errorf("failed to get git log: %w", err)
1602 }
1603
1604 // Parse git log output and filter out already seen commits
1605 parsedCommits := parseGitLog(string(output))
1606
1607 var headCommit *GitCommit
1608
1609 // Filter out commits we've already seen
1610 for _, commit := range parsedCommits {
1611 if commit.Hash == head {
1612 headCommit = &commit
1613 }
1614
1615 // Skip if we've seen this commit before. If our head has changed, always include that.
1616 if a.seenCommits[commit.Hash] && commit.Hash != head {
1617 continue
1618 }
1619
1620 // Mark this commit as seen
1621 a.seenCommits[commit.Hash] = true
1622
1623 // Add to our list of new commits
1624 commits = append(commits, &commit)
1625 }
1626
1627 if a.gitRemoteAddr != "" {
1628 if headCommit == nil {
1629 // I think this can only happen if we have a bug or if there's a race.
1630 headCommit = &GitCommit{}
1631 headCommit.Hash = head
1632 headCommit.Subject = "unknown"
1633 commits = append(commits, headCommit)
1634 }
1635
Philip Zeyliger113e2052025-05-09 21:59:40 +00001636 originalBranch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
1637 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001638
1639 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1640 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1641 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001642
1643 // Try up to 10 times with different branch names if the branch is checked out on the remote
1644 var out []byte
1645 var err error
1646 for retries := range 10 {
1647 if retries > 0 {
1648 // Add a numeric suffix to the branch name
1649 branch = fmt.Sprintf("%s%d", originalBranch, retries)
1650 }
1651
1652 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1653 cmd.Dir = a.workingDir
1654 out, err = cmd.CombinedOutput()
1655
1656 if err == nil {
1657 // Success! Break out of the retry loop
1658 break
1659 }
1660
1661 // Check if this is the "refusing to update checked out branch" error
1662 if !strings.Contains(string(out), "refusing to update checked out branch") {
1663 // This is a different error, so don't retry
1664 break
1665 }
1666
1667 // If we're on the last retry, we'll report the error
1668 if retries == 9 {
1669 break
1670 }
1671 }
1672
1673 if err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001674 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1675 } else {
1676 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001677 // Update the agent's branch name if we ended up using a different one
1678 if branch != originalBranch {
1679 a.branchName = branch
1680 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001681 }
1682 }
1683
1684 // If we found new commits, create a message
1685 if len(commits) > 0 {
1686 msg := AgentMessage{
1687 Type: CommitMessageType,
1688 Timestamp: time.Now(),
1689 Commits: commits,
1690 }
1691 a.pushToOutbox(ctx, msg)
1692 }
1693 return commits, nil
1694}
1695
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001696func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001697 return strings.Map(func(r rune) rune {
1698 // lowercase
1699 if r >= 'A' && r <= 'Z' {
1700 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001701 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001702 // replace spaces with dashes
1703 if r == ' ' {
1704 return '-'
1705 }
1706 // allow alphanumerics and dashes
1707 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1708 return r
1709 }
1710 return -1
1711 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001712}
1713
1714// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1715// and returns an array of GitCommit structs.
1716func parseGitLog(output string) []GitCommit {
1717 var commits []GitCommit
1718
1719 // No output means no commits
1720 if len(output) == 0 {
1721 return commits
1722 }
1723
1724 // Split by NULL byte
1725 parts := strings.Split(output, "\x00")
1726
1727 // Process in triplets (hash, subject, body)
1728 for i := 0; i < len(parts); i++ {
1729 // Skip empty parts
1730 if parts[i] == "" {
1731 continue
1732 }
1733
1734 // This should be a hash
1735 hash := strings.TrimSpace(parts[i])
1736
1737 // Make sure we have at least a subject part available
1738 if i+1 >= len(parts) {
1739 break // No more parts available
1740 }
1741
1742 // Get the subject
1743 subject := strings.TrimSpace(parts[i+1])
1744
1745 // Get the body if available
1746 body := ""
1747 if i+2 < len(parts) {
1748 body = strings.TrimSpace(parts[i+2])
1749 }
1750
1751 // Skip to the next triplet
1752 i += 2
1753
1754 commits = append(commits, GitCommit{
1755 Hash: hash,
1756 Subject: subject,
1757 Body: body,
1758 })
1759 }
1760
1761 return commits
1762}
1763
1764func repoRoot(ctx context.Context, dir string) (string, error) {
1765 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1766 stderr := new(strings.Builder)
1767 cmd.Stderr = stderr
1768 cmd.Dir = dir
1769 out, err := cmd.Output()
1770 if err != nil {
1771 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1772 }
1773 return strings.TrimSpace(string(out)), nil
1774}
1775
1776func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1777 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1778 stderr := new(strings.Builder)
1779 cmd.Stderr = stderr
1780 cmd.Dir = dir
1781 out, err := cmd.Output()
1782 if err != nil {
1783 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1784 }
1785 // TODO: validate that out is valid hex
1786 return strings.TrimSpace(string(out)), nil
1787}
1788
1789// isValidGitSHA validates if a string looks like a valid git SHA hash.
1790// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1791func isValidGitSHA(sha string) bool {
1792 // Git SHA must be a hexadecimal string with at least 4 characters
1793 if len(sha) < 4 || len(sha) > 40 {
1794 return false
1795 }
1796
1797 // Check if the string only contains hexadecimal characters
1798 for _, char := range sha {
1799 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1800 return false
1801 }
1802 }
1803
1804 return true
1805}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001806
1807// getGitOrigin returns the URL of the git remote 'origin' if it exists
1808func getGitOrigin(ctx context.Context, dir string) string {
1809 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1810 cmd.Dir = dir
1811 stderr := new(strings.Builder)
1812 cmd.Stderr = stderr
1813 out, err := cmd.Output()
1814 if err != nil {
1815 return ""
1816 }
1817 return strings.TrimSpace(string(out))
1818}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001819
1820func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1821 cmd := exec.CommandContext(ctx, "git", "stash")
1822 cmd.Dir = workingDir
1823 if out, err := cmd.CombinedOutput(); err != nil {
1824 return fmt.Errorf("git stash: %s: %v", out, err)
1825 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001826 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001827 cmd.Dir = workingDir
1828 if out, err := cmd.CombinedOutput(); err != nil {
1829 return fmt.Errorf("git fetch: %s: %w", out, err)
1830 }
1831 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1832 cmd.Dir = workingDir
1833 if out, err := cmd.CombinedOutput(); err != nil {
1834 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1835 }
1836 a.lastHEAD = revision
1837 a.initialCommit = revision
1838 return nil
1839}
1840
1841func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1842 a.mu.Lock()
1843 a.title = ""
1844 a.firstMessageIndex = len(a.history)
1845 a.convo = a.initConvo()
1846 gitReset := func() error {
1847 if a.config.InDocker && rev != "" {
1848 err := a.initGitRevision(ctx, a.workingDir, rev)
1849 if err != nil {
1850 return err
1851 }
1852 } else if !a.config.InDocker && rev != "" {
1853 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1854 }
1855 return nil
1856 }
1857 err := gitReset()
1858 a.mu.Unlock()
1859 if err != nil {
1860 a.pushToOutbox(a.config.Context, errorMessage(err))
1861 }
1862
1863 a.pushToOutbox(a.config.Context, AgentMessage{
1864 Type: AgentMessageType, Content: "Conversation restarted.",
1865 })
1866 if initialPrompt != "" {
1867 a.UserMessage(ctx, initialPrompt)
1868 }
1869 return nil
1870}
1871
1872func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1873 msg := `The user has requested a suggestion for a re-prompt.
1874
1875 Given the current conversation thus far, suggest a re-prompt that would
1876 capture the instructions and feedback so far, as well as any
1877 research or other information that would be helpful in implementing
1878 the task.
1879
1880 Reply with ONLY the reprompt text.
1881 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001882 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001883 // By doing this in a subconversation, the agent doesn't call tools (because
1884 // there aren't any), and there's not a concurrency risk with on-going other
1885 // outstanding conversations.
1886 convo := a.convo.SubConvoWithHistory()
1887 resp, err := convo.SendMessage(userMessage)
1888 if err != nil {
1889 a.pushToOutbox(ctx, errorMessage(err))
1890 return "", err
1891 }
1892 textContent := collectTextContent(resp)
1893 return textContent, nil
1894}
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001895
1896// systemPromptData contains the data used to render the system prompt template
1897type systemPromptData struct {
1898 EditPrompt string
1899 ClientGOOS string
1900 ClientGOARCH string
1901 WorkingDir string
1902 RepoRoot string
1903 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001904 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001905}
1906
1907// renderSystemPrompt renders the system prompt template.
1908func (a *Agent) renderSystemPrompt() string {
1909 // Determine the appropriate edit prompt based on config
1910 var editPrompt string
1911 if a.config.UseAnthropicEdit {
1912 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."
1913 } else {
1914 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1915 }
1916
1917 data := systemPromptData{
1918 EditPrompt: editPrompt,
1919 ClientGOOS: a.config.ClientGOOS,
1920 ClientGOARCH: a.config.ClientGOARCH,
1921 WorkingDir: a.workingDir,
1922 RepoRoot: a.repoRoot,
1923 InitialCommit: a.initialCommit,
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001924 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001925 }
1926
1927 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1928 if err != nil {
1929 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1930 }
1931 buf := new(strings.Builder)
1932 err = tmpl.Execute(buf, data)
1933 if err != nil {
1934 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1935 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001936 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001937 return buf.String()
1938}