blob: 81e120065f50b9e4c04362b0cf7aa2e92e0813fa [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
Earl Lee2e463fb2025-04-17 11:22:22 -0700549}
550
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000551// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700552func (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 +0000553 // Track the tool call
554 a.mu.Lock()
555 a.outstandingToolCalls[id] = toolName
556 a.mu.Unlock()
557}
558
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700559// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
560// If there's only one element in the array and it's a text type, it returns that text directly.
561// It also processes nested ToolResult arrays recursively.
562func contentToString(contents []llm.Content) string {
563 if len(contents) == 0 {
564 return ""
565 }
566
567 // If there's only one element and it's a text type, return it directly
568 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
569 return contents[0].Text
570 }
571
572 // Otherwise, concatenate all text content
573 var result strings.Builder
574 for _, content := range contents {
575 if content.Type == llm.ContentTypeText {
576 result.WriteString(content.Text)
577 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
578 // Recursively process nested tool results
579 result.WriteString(contentToString(content.ToolResult))
580 }
581 }
582
583 return result.String()
584}
585
Earl Lee2e463fb2025-04-17 11:22:22 -0700586// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700587func (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 +0000588 // Remove the tool call from outstanding calls
589 a.mu.Lock()
590 delete(a.outstandingToolCalls, toolID)
591 a.mu.Unlock()
592
Earl Lee2e463fb2025-04-17 11:22:22 -0700593 m := AgentMessage{
594 Type: ToolUseMessageType,
595 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700596 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700597 ToolError: content.ToolError,
598 ToolName: toolName,
599 ToolInput: string(toolInput),
600 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700601 StartTime: content.ToolUseStartTime,
602 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700603 }
604
605 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700606 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
607 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700608 m.Elapsed = &elapsed
609 }
610
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700611 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700612 a.pushToOutbox(ctx, m)
613}
614
615// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700616func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000617 a.mu.Lock()
618 defer a.mu.Unlock()
619 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700620 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
621}
622
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700623// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700624// that need to be displayed (as well as tool calls that we send along when
625// they're done). (It would be reasonable to also mention tool calls when they're
626// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700627func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000628 // Remove the LLM call from outstanding calls
629 a.mu.Lock()
630 delete(a.outstandingLLMCalls, id)
631 a.mu.Unlock()
632
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700633 if resp == nil {
634 // LLM API call failed
635 m := AgentMessage{
636 Type: ErrorMessageType,
637 Content: "API call failed, type 'continue' to try again",
638 }
639 m.SetConvo(convo)
640 a.pushToOutbox(ctx, m)
641 return
642 }
643
Earl Lee2e463fb2025-04-17 11:22:22 -0700644 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700645 if convo.Parent == nil { // subconvos never end the turn
646 switch resp.StopReason {
647 case llm.StopReasonToolUse:
648 // Check whether any of the tool calls are for tools that should end the turn
649 ToolSearch:
650 for _, part := range resp.Content {
651 if part.Type != llm.ContentTypeToolUse {
652 continue
653 }
Sean McCullough021557a2025-05-05 23:20:53 +0000654 // Find the tool by name
655 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700656 if tool.Name == part.ToolName {
657 endOfTurn = tool.EndsTurn
658 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000659 }
660 }
Sean McCullough021557a2025-05-05 23:20:53 +0000661 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700662 default:
663 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000664 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700665 }
666 m := AgentMessage{
667 Type: AgentMessageType,
668 Content: collectTextContent(resp),
669 EndOfTurn: endOfTurn,
670 Usage: &resp.Usage,
671 StartTime: resp.StartTime,
672 EndTime: resp.EndTime,
673 }
674
675 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700676 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700677 var toolCalls []ToolCall
678 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700679 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700680 toolCalls = append(toolCalls, ToolCall{
681 Name: part.ToolName,
682 Input: string(part.ToolInput),
683 ToolCallId: part.ID,
684 })
685 }
686 }
687 m.ToolCalls = toolCalls
688 }
689
690 // Calculate the elapsed time if both start and end times are set
691 if resp.StartTime != nil && resp.EndTime != nil {
692 elapsed := resp.EndTime.Sub(*resp.StartTime)
693 m.Elapsed = &elapsed
694 }
695
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700696 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700697 a.pushToOutbox(ctx, m)
698}
699
700// WorkingDir implements CodingAgent.
701func (a *Agent) WorkingDir() string {
702 return a.workingDir
703}
704
705// MessageCount implements CodingAgent.
706func (a *Agent) MessageCount() int {
707 a.mu.Lock()
708 defer a.mu.Unlock()
709 return len(a.history)
710}
711
712// Messages implements CodingAgent.
713func (a *Agent) Messages(start int, end int) []AgentMessage {
714 a.mu.Lock()
715 defer a.mu.Unlock()
716 return slices.Clone(a.history[start:end])
717}
718
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700719func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700720 return a.originalBudget
721}
722
723// AgentConfig contains configuration for creating a new Agent.
724type AgentConfig struct {
725 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700726 Service llm.Service
727 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700728 GitUsername string
729 GitEmail string
730 SessionID string
731 ClientGOOS string
732 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700733 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700734 UseAnthropicEdit bool
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000735 OneShot bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000736 // Outside information
737 OutsideHostname string
738 OutsideOS string
739 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700740}
741
742// NewAgent creates a new Agent.
743// It is not usable until Init() is called.
744func NewAgent(config AgentConfig) *Agent {
745 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000746 config: config,
747 ready: make(chan struct{}),
748 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700749 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000750 startedAt: time.Now(),
751 originalBudget: config.Budget,
752 seenCommits: make(map[string]bool),
753 outsideHostname: config.OutsideHostname,
754 outsideOS: config.OutsideOS,
755 outsideWorkingDir: config.OutsideWorkingDir,
756 outstandingLLMCalls: make(map[string]struct{}),
757 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700758 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700759 }
760 return agent
761}
762
763type AgentInit struct {
764 WorkingDir string
765 NoGit bool // only for testing
766
767 InDocker bool
768 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000769 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700770 GitRemoteAddr string
771 HostAddr string
772}
773
774func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700775 if a.convo != nil {
776 return fmt.Errorf("Agent.Init: already initialized")
777 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700778 ctx := a.config.Context
779 if ini.InDocker {
780 cmd := exec.CommandContext(ctx, "git", "stash")
781 cmd.Dir = ini.WorkingDir
782 if out, err := cmd.CombinedOutput(); err != nil {
783 return fmt.Errorf("git stash: %s: %v", out, err)
784 }
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700785 // sketch-host is a git repo hosted by "outtie sketch". When it notices a 'git fetch',
786 // it runs "git fetch" underneath the covers to get its latest commits. By configuring
787 // an additional remote.sketch-host.fetch, we make "origin/main" on innie sketch look like
788 // origin/main on outtie sketch, which should make it easier to rebase.
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700789 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
790 cmd.Dir = ini.WorkingDir
791 if out, err := cmd.CombinedOutput(); err != nil {
792 return fmt.Errorf("git remote add: %s: %v", out, err)
793 }
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700794 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.sketch-host.fetch",
795 "+refs/heads/feature/*:refs/remotes/origin/feature/*")
796 cmd.Dir = ini.WorkingDir
797 if out, err := cmd.CombinedOutput(); err != nil {
798 return fmt.Errorf("git config --add: %s: %v", out, err)
799 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000800 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700801 cmd.Dir = ini.WorkingDir
802 if out, err := cmd.CombinedOutput(); err != nil {
803 return fmt.Errorf("git fetch: %s: %w", out, err)
804 }
805 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
806 cmd.Dir = ini.WorkingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100807 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
808 // Remove git hooks if they exist and retry
809 // Only try removing hooks if we haven't already removed them during fetch
810 hookPath := filepath.Join(ini.WorkingDir, ".git", "hooks")
811 if _, statErr := os.Stat(hookPath); statErr == nil {
812 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
813 slog.String("error", err.Error()),
814 slog.String("output", string(checkoutOut)))
815 if removeErr := removeGitHooks(ctx, ini.WorkingDir); removeErr != nil {
816 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
817 }
818
819 // Retry the checkout operation
820 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
821 cmd.Dir = ini.WorkingDir
822 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
823 return fmt.Errorf("git checkout %s failed even after removing hooks: %s: %w", ini.Commit, retryOut, retryErr)
824 }
825 } else {
826 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, checkoutOut, err)
827 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700828 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700829 a.lastHEAD = ini.Commit
830 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000831 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700832 a.initialCommit = ini.Commit
833 if ini.HostAddr != "" {
834 a.url = "http://" + ini.HostAddr
835 }
836 }
837 a.workingDir = ini.WorkingDir
838
839 if !ini.NoGit {
840 repoRoot, err := repoRoot(ctx, a.workingDir)
841 if err != nil {
842 return fmt.Errorf("repoRoot: %w", err)
843 }
844 a.repoRoot = repoRoot
845
846 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
847 if err != nil {
848 return fmt.Errorf("resolveRef: %w", err)
849 }
850 a.initialCommit = commitHash
851
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000852 if experiment.Enabled("memory") {
853 slog.Info("running codebase analysis")
854 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
855 if err != nil {
856 slog.Warn("failed to analyze codebase", "error", err)
857 }
858 a.codebase = codebase
859 }
860
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000861 llmCodeReview := codereview.NoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700862 if experiment.Enabled("llm_review") {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000863 llmCodeReview = codereview.DoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700864 }
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000865 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit, llmCodeReview)
Earl Lee2e463fb2025-04-17 11:22:22 -0700866 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000867 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700868 }
869 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000870
871 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700872 }
873 a.lastHEAD = a.initialCommit
874 a.convo = a.initConvo()
875 close(a.ready)
876 return nil
877}
878
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700879//go:embed agent_system_prompt.txt
880var agentSystemPrompt string
881
Earl Lee2e463fb2025-04-17 11:22:22 -0700882// initConvo initializes the conversation.
883// It must not be called until all agent fields are initialized,
884// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700885func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700886 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700887 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700888 convo.PromptCaching = true
889 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000890 convo.SystemPrompt = a.renderSystemPrompt()
Earl Lee2e463fb2025-04-17 11:22:22 -0700891
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000892 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
893 bashPermissionCheck := func(command string) error {
894 // Check if branch name is set
895 a.mu.Lock()
896 branchSet := a.branchName != ""
897 a.mu.Unlock()
898
899 // If branch is set, all commands are allowed
900 if branchSet {
901 return nil
902 }
903
904 // If branch is not set, check if this is a git commit command
905 willCommit, err := bashkit.WillRunGitCommit(command)
906 if err != nil {
907 // If there's an error checking, we should allow the command to proceed
908 return nil
909 }
910
911 // If it's a git commit and branch is not set, return an error
912 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000913 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000914 }
915
916 return nil
917 }
918
919 // Create a custom bash tool with the permission check
920 bashTool := claudetool.NewBashTool(bashPermissionCheck)
921
Earl Lee2e463fb2025-04-17 11:22:22 -0700922 // Register all tools with the conversation
923 // When adding, removing, or modifying tools here, double-check that the termui tool display
924 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000925
926 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -0700927 _, supportsScreenshots := a.config.Service.(*ant.Service)
928 var bTools []*llm.Tool
929 var browserCleanup func()
930
931 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
932 // Add cleanup function to context cancel
933 go func() {
934 <-a.config.Context.Done()
935 browserCleanup()
936 }()
937 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000938
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700939 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000940 bashTool, claudetool.Keyword,
Josh Bleecher Snyder93202652025-05-08 02:05:57 +0000941 claudetool.Think, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000942 a.codereview.Tool(),
943 }
944
945 // One-shot mode is non-interactive, multiple choice requires human response
946 if !a.config.OneShot {
947 convo.Tools = append(convo.Tools, a.multipleChoiceTool())
Earl Lee2e463fb2025-04-17 11:22:22 -0700948 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000949
950 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -0700951 if a.config.UseAnthropicEdit {
952 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
953 } else {
954 convo.Tools = append(convo.Tools, claudetool.Patch)
955 }
956 convo.Listener = a
957 return convo
958}
959
Sean McCullough485afc62025-04-28 14:28:39 -0700960func (a *Agent) multipleChoiceTool() *llm.Tool {
961 ret := &llm.Tool{
962 Name: "multiplechoice",
963 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 +0000964 EndsTurn: true,
Sean McCullough485afc62025-04-28 14:28:39 -0700965 InputSchema: json.RawMessage(`{
966 "type": "object",
967 "description": "The question and a list of answers you would expect the user to choose from.",
968 "properties": {
969 "question": {
970 "type": "string",
971 "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?'"
972 },
973 "responseOptions": {
974 "type": "array",
975 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
976 "items": {
977 "type": "object",
978 "properties": {
979 "caption": {
980 "type": "string",
981 "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'"
982 },
983 "responseText": {
984 "type": "string",
985 "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'"
986 }
987 },
988 "required": ["caption", "responseText"]
989 }
990 }
991 },
992 "required": ["question", "responseOptions"]
993}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700994 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Sean McCullough485afc62025-04-28 14:28:39 -0700995 // The Run logic for "multiplchoice" tool is a no-op on the server.
996 // The UI will present a list of options for the user to select from,
997 // and that's it as far as "executing" the tool_use goes.
998 // When the user *does* select one of the presented options, that
999 // responseText gets sent as a chat message on behalf of the user.
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001000 return llm.TextContent("end your turn and wait for the user to respond"), nil
Sean McCullough485afc62025-04-28 14:28:39 -07001001 },
1002 }
1003 return ret
1004}
1005
1006type MultipleChoiceOption struct {
1007 Caption string `json:"caption"`
1008 ResponseText string `json:"responseText"`
1009}
1010
1011type MultipleChoiceParams struct {
1012 Question string `json:"question"`
1013 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1014}
1015
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001016// branchExists reports whether branchName exists, either locally or in well-known remotes.
1017func branchExists(dir, branchName string) bool {
1018 refs := []string{
1019 "refs/heads/",
1020 "refs/remotes/origin/",
1021 "refs/remotes/sketch-host/",
1022 }
1023 for _, ref := range refs {
1024 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1025 cmd.Dir = dir
1026 if cmd.Run() == nil { // exit code 0 means branch exists
1027 return true
1028 }
1029 }
1030 return false
1031}
1032
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001033func (a *Agent) titleTool() *llm.Tool {
1034 description := `Sets the conversation title.`
1035 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001036 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001037 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001038 InputSchema: json.RawMessage(`{
1039 "type": "object",
1040 "properties": {
1041 "title": {
1042 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001043 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001044 }
1045 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001046 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001047}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001048 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001049 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001050 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001051 }
1052 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001053 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001054 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001055
1056 // We don't allow changing the title once set to be consistent with the previous behavior
1057 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001058 t := a.Title()
1059 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001060 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001061 }
1062
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001063 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001064 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001065 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001066
1067 a.SetTitle(params.Title)
1068 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001069 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001070 },
1071 }
1072 return titleTool
1073}
1074
1075func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001076 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 +00001077 preCommit := &llm.Tool{
1078 Name: "precommit",
1079 Description: description,
1080 InputSchema: json.RawMessage(`{
1081 "type": "object",
1082 "properties": {
1083 "branch_name": {
1084 "type": "string",
1085 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1086 }
1087 },
1088 "required": ["branch_name"]
1089}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001090 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001091 var params struct {
1092 BranchName string `json:"branch_name"`
1093 }
1094 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001095 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001096 }
1097
1098 b := a.BranchName()
1099 if b != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001100 return nil, fmt.Errorf("branch already set to: %s", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001101 }
1102
1103 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001104 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001105 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001106 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001107 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001108 }
1109 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001110 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001111 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001112 }
1113
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001114 a.SetBranch(branchName)
1115 response := fmt.Sprintf("Branch name set to %q", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001116
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001117 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1118 if err != nil {
1119 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1120 }
1121 if len(styleHint) > 0 {
1122 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001123 }
1124
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001125 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001126 },
1127 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001128 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001129}
1130
1131func (a *Agent) Ready() <-chan struct{} {
1132 return a.ready
1133}
1134
1135func (a *Agent) UserMessage(ctx context.Context, msg string) {
1136 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1137 a.inbox <- msg
1138}
1139
Earl Lee2e463fb2025-04-17 11:22:22 -07001140func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1141 return a.convo.CancelToolUse(toolUseID, cause)
1142}
1143
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001144func (a *Agent) CancelTurn(cause error) {
1145 a.cancelTurnMu.Lock()
1146 defer a.cancelTurnMu.Unlock()
1147 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001148 // Force state transition to cancelled state
1149 ctx := a.config.Context
1150 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001151 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001152 }
1153}
1154
1155func (a *Agent) Loop(ctxOuter context.Context) {
1156 for {
1157 select {
1158 case <-ctxOuter.Done():
1159 return
1160 default:
1161 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001162 a.cancelTurnMu.Lock()
1163 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001164 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001165 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001166 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001167 a.cancelTurn = cancel
1168 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001169 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1170 if err != nil {
1171 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1172 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001173 cancel(nil)
1174 }
1175 }
1176}
1177
1178func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1179 if m.Timestamp.IsZero() {
1180 m.Timestamp = time.Now()
1181 }
1182
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001183 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1184 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1185 m.Content = m.ToolResult
1186 }
1187
Earl Lee2e463fb2025-04-17 11:22:22 -07001188 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1189 if m.EndOfTurn && m.Type == AgentMessageType {
1190 turnDuration := time.Since(a.startOfTurn)
1191 m.TurnDuration = &turnDuration
1192 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1193 }
1194
Earl Lee2e463fb2025-04-17 11:22:22 -07001195 a.mu.Lock()
1196 defer a.mu.Unlock()
1197 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001198 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001199 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001200
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001201 // Notify all subscribers
1202 for _, ch := range a.subscribers {
1203 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001204 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001205}
1206
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001207func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1208 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001209 if block {
1210 select {
1211 case <-ctx.Done():
1212 return m, ctx.Err()
1213 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001214 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001215 }
1216 }
1217 for {
1218 select {
1219 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001220 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001221 default:
1222 return m, nil
1223 }
1224 }
1225}
1226
Sean McCullough885a16a2025-04-30 02:49:25 +00001227// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001228func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001229 // Reset the start of turn time
1230 a.startOfTurn = time.Now()
1231
Sean McCullough96b60dd2025-04-30 09:49:10 -07001232 // Transition to waiting for user input state
1233 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1234
Sean McCullough885a16a2025-04-30 02:49:25 +00001235 // Process initial user message
1236 initialResp, err := a.processUserMessage(ctx)
1237 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001238 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001239 return err
1240 }
1241
1242 // Handle edge case where both initialResp and err are nil
1243 if initialResp == nil {
1244 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001245 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1246
Sean McCullough9f4b8082025-04-30 17:34:07 +00001247 a.pushToOutbox(ctx, errorMessage(err))
1248 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001249 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001250
Earl Lee2e463fb2025-04-17 11:22:22 -07001251 // We do this as we go, but let's also do it at the end of the turn
1252 defer func() {
1253 if _, err := a.handleGitCommits(ctx); err != nil {
1254 // Just log the error, don't stop execution
1255 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1256 }
1257 }()
1258
Sean McCullougha1e0e492025-05-01 10:51:08 -07001259 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001260 resp := initialResp
1261 for {
1262 // Check if we are over budget
1263 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001264 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001265 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001266 }
1267
1268 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001269 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001270 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001271 break
1272 }
1273
Sean McCullough96b60dd2025-04-30 09:49:10 -07001274 // Transition to tool use requested state
1275 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1276
Sean McCullough885a16a2025-04-30 02:49:25 +00001277 // Handle tool execution
1278 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1279 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001280 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001281 }
1282
Sean McCullougha1e0e492025-05-01 10:51:08 -07001283 if toolResp == nil {
1284 return fmt.Errorf("cannot continue conversation with a nil tool response")
1285 }
1286
Sean McCullough885a16a2025-04-30 02:49:25 +00001287 // Set the response for the next iteration
1288 resp = toolResp
1289 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001290
1291 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001292}
1293
1294// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001295func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001296 // Wait for at least one message from the user
1297 msgs, err := a.GatherMessages(ctx, true)
1298 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001299 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001300 return nil, err
1301 }
1302
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001303 userMessage := llm.Message{
1304 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001305 Content: msgs,
1306 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001307
Sean McCullough96b60dd2025-04-30 09:49:10 -07001308 // Transition to sending to LLM state
1309 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1310
Sean McCullough885a16a2025-04-30 02:49:25 +00001311 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001312 resp, err := a.convo.SendMessage(userMessage)
1313 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001314 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001315 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001316 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001317 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001318
Sean McCullough96b60dd2025-04-30 09:49:10 -07001319 // Transition to processing LLM response state
1320 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1321
Sean McCullough885a16a2025-04-30 02:49:25 +00001322 return resp, nil
1323}
1324
1325// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001326func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1327 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001328 cancelled := false
1329
Sean McCullough96b60dd2025-04-30 09:49:10 -07001330 // Transition to checking for cancellation state
1331 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1332
Sean McCullough885a16a2025-04-30 02:49:25 +00001333 // Check if the operation was cancelled by the user
1334 select {
1335 case <-ctx.Done():
1336 // Don't actually run any of the tools, but rather build a response
1337 // for each tool_use message letting the LLM know that user canceled it.
1338 var err error
1339 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001340 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001341 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001342 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001343 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001344 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001345 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001346 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001347 // Transition to running tool state
1348 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1349
Sean McCullough885a16a2025-04-30 02:49:25 +00001350 // Add working directory to context for tool execution
1351 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1352
1353 // Execute the tools
1354 var err error
1355 results, err = a.convo.ToolResultContents(ctx, resp)
1356 if ctx.Err() != nil { // e.g. the user canceled the operation
1357 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001358 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001359 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001360 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001361 a.pushToOutbox(ctx, errorMessage(err))
1362 }
1363 }
1364
1365 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001366 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001367 autoqualityMessages := a.processGitChanges(ctx)
1368
1369 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001370 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001371 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001372 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001373 return false, nil
1374 }
1375
1376 // Continue the conversation with tool results and any user messages
1377 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1378}
1379
1380// processGitChanges checks for new git commits and runs autoformatters if needed
1381func (a *Agent) processGitChanges(ctx context.Context) []string {
1382 // Check for git commits after tool execution
1383 newCommits, err := a.handleGitCommits(ctx)
1384 if err != nil {
1385 // Just log the error, don't stop execution
1386 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1387 return nil
1388 }
1389
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001390 // Run mechanical checks if there was exactly one new commit.
1391 if len(newCommits) != 1 {
1392 return nil
1393 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001394 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001395 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1396 msg := a.codereview.RunMechanicalChecks(ctx)
1397 if msg != "" {
1398 a.pushToOutbox(ctx, AgentMessage{
1399 Type: AutoMessageType,
1400 Content: msg,
1401 Timestamp: time.Now(),
1402 })
1403 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001404 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001405
1406 return autoqualityMessages
1407}
1408
1409// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001410func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001411 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001412 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001413 msgs, err := a.GatherMessages(ctx, false)
1414 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001415 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001416 return false, nil
1417 }
1418
1419 // Inject any auto-generated messages from quality checks
1420 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001421 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001422 }
1423
1424 // Handle cancellation by appending a message about it
1425 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001426 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001427 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001428 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001429 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1430 } else if err := a.convo.OverBudget(); err != nil {
1431 // Handle budget issues by appending a message about it
1432 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 -07001433 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001434 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1435 }
1436
1437 // Combine tool results with user messages
1438 results = append(results, msgs...)
1439
1440 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001441 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001442 resp, err := a.convo.SendMessage(llm.Message{
1443 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001444 Content: results,
1445 })
1446 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001447 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001448 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1449 return true, nil // Return true to continue the conversation, but with no response
1450 }
1451
Sean McCullough96b60dd2025-04-30 09:49:10 -07001452 // Transition back to processing LLM response
1453 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1454
Sean McCullough885a16a2025-04-30 02:49:25 +00001455 if cancelled {
1456 return false, nil
1457 }
1458
1459 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001460}
1461
1462func (a *Agent) overBudget(ctx context.Context) error {
1463 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001464 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001465 m := budgetMessage(err)
1466 m.Content = m.Content + "\n\nBudget reset."
1467 a.pushToOutbox(ctx, budgetMessage(err))
1468 a.convo.ResetBudget(a.originalBudget)
1469 return err
1470 }
1471 return nil
1472}
1473
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001474func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001475 // Collect all text content
1476 var allText strings.Builder
1477 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001478 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001479 if allText.Len() > 0 {
1480 allText.WriteString("\n\n")
1481 }
1482 allText.WriteString(content.Text)
1483 }
1484 }
1485 return allText.String()
1486}
1487
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001488func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001489 a.mu.Lock()
1490 defer a.mu.Unlock()
1491 return a.convo.CumulativeUsage()
1492}
1493
Earl Lee2e463fb2025-04-17 11:22:22 -07001494// Diff returns a unified diff of changes made since the agent was instantiated.
1495func (a *Agent) Diff(commit *string) (string, error) {
1496 if a.initialCommit == "" {
1497 return "", fmt.Errorf("no initial commit reference available")
1498 }
1499
1500 // Find the repository root
1501 ctx := context.Background()
1502
1503 // If a specific commit hash is provided, show just that commit's changes
1504 if commit != nil && *commit != "" {
1505 // Validate that the commit looks like a valid git SHA
1506 if !isValidGitSHA(*commit) {
1507 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1508 }
1509
1510 // Get the diff for just this commit
1511 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1512 cmd.Dir = a.repoRoot
1513 output, err := cmd.CombinedOutput()
1514 if err != nil {
1515 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1516 }
1517 return string(output), nil
1518 }
1519
1520 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1521 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1522 cmd.Dir = a.repoRoot
1523 output, err := cmd.CombinedOutput()
1524 if err != nil {
1525 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1526 }
1527
1528 return string(output), nil
1529}
1530
1531// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1532func (a *Agent) InitialCommit() string {
1533 return a.initialCommit
1534}
1535
Pokey Rule7a113622025-05-12 10:58:45 +01001536// removeGitHooks removes the Git hooks directory from the repository
1537func removeGitHooks(_ context.Context, repoPath string) error {
1538 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1539
1540 // Check if hooks directory exists
1541 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1542 // Directory doesn't exist, nothing to do
1543 return nil
1544 }
1545
1546 // Remove the hooks directory
1547 err := os.RemoveAll(hooksDir)
1548 if err != nil {
1549 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1550 }
1551
1552 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001553 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001554 if err != nil {
1555 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1556 }
1557
1558 return nil
1559}
1560
Earl Lee2e463fb2025-04-17 11:22:22 -07001561// handleGitCommits() highlights new commits to the user. When running
1562// under docker, new HEADs are pushed to a branch according to the title.
1563func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1564 if a.repoRoot == "" {
1565 return nil, nil
1566 }
1567
1568 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1569 if err != nil {
1570 return nil, err
1571 }
1572 if head == a.lastHEAD {
1573 return nil, nil // nothing to do
1574 }
1575 defer func() {
1576 a.lastHEAD = head
1577 }()
1578
1579 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1580 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1581 // to the last 100 commits.
1582 var commits []*GitCommit
1583
1584 // Get commits since the initial commit
1585 // Format: <hash>\0<subject>\0<body>\0
1586 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1587 // Limit to 100 commits to avoid overwhelming the user
1588 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1589 cmd.Dir = a.repoRoot
1590 output, err := cmd.Output()
1591 if err != nil {
1592 return nil, fmt.Errorf("failed to get git log: %w", err)
1593 }
1594
1595 // Parse git log output and filter out already seen commits
1596 parsedCommits := parseGitLog(string(output))
1597
1598 var headCommit *GitCommit
1599
1600 // Filter out commits we've already seen
1601 for _, commit := range parsedCommits {
1602 if commit.Hash == head {
1603 headCommit = &commit
1604 }
1605
1606 // Skip if we've seen this commit before. If our head has changed, always include that.
1607 if a.seenCommits[commit.Hash] && commit.Hash != head {
1608 continue
1609 }
1610
1611 // Mark this commit as seen
1612 a.seenCommits[commit.Hash] = true
1613
1614 // Add to our list of new commits
1615 commits = append(commits, &commit)
1616 }
1617
1618 if a.gitRemoteAddr != "" {
1619 if headCommit == nil {
1620 // I think this can only happen if we have a bug or if there's a race.
1621 headCommit = &GitCommit{}
1622 headCommit.Hash = head
1623 headCommit.Subject = "unknown"
1624 commits = append(commits, headCommit)
1625 }
1626
Philip Zeyliger113e2052025-05-09 21:59:40 +00001627 originalBranch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
1628 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001629
1630 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1631 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1632 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001633
1634 // Try up to 10 times with different branch names if the branch is checked out on the remote
1635 var out []byte
1636 var err error
1637 for retries := range 10 {
1638 if retries > 0 {
1639 // Add a numeric suffix to the branch name
1640 branch = fmt.Sprintf("%s%d", originalBranch, retries)
1641 }
1642
1643 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1644 cmd.Dir = a.workingDir
1645 out, err = cmd.CombinedOutput()
1646
1647 if err == nil {
1648 // Success! Break out of the retry loop
1649 break
1650 }
1651
1652 // Check if this is the "refusing to update checked out branch" error
1653 if !strings.Contains(string(out), "refusing to update checked out branch") {
1654 // This is a different error, so don't retry
1655 break
1656 }
1657
1658 // If we're on the last retry, we'll report the error
1659 if retries == 9 {
1660 break
1661 }
1662 }
1663
1664 if err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001665 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1666 } else {
1667 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001668 // Update the agent's branch name if we ended up using a different one
1669 if branch != originalBranch {
1670 a.branchName = branch
1671 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001672 }
1673 }
1674
1675 // If we found new commits, create a message
1676 if len(commits) > 0 {
1677 msg := AgentMessage{
1678 Type: CommitMessageType,
1679 Timestamp: time.Now(),
1680 Commits: commits,
1681 }
1682 a.pushToOutbox(ctx, msg)
1683 }
1684 return commits, nil
1685}
1686
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001687func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001688 return strings.Map(func(r rune) rune {
1689 // lowercase
1690 if r >= 'A' && r <= 'Z' {
1691 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001692 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001693 // replace spaces with dashes
1694 if r == ' ' {
1695 return '-'
1696 }
1697 // allow alphanumerics and dashes
1698 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1699 return r
1700 }
1701 return -1
1702 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001703}
1704
1705// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1706// and returns an array of GitCommit structs.
1707func parseGitLog(output string) []GitCommit {
1708 var commits []GitCommit
1709
1710 // No output means no commits
1711 if len(output) == 0 {
1712 return commits
1713 }
1714
1715 // Split by NULL byte
1716 parts := strings.Split(output, "\x00")
1717
1718 // Process in triplets (hash, subject, body)
1719 for i := 0; i < len(parts); i++ {
1720 // Skip empty parts
1721 if parts[i] == "" {
1722 continue
1723 }
1724
1725 // This should be a hash
1726 hash := strings.TrimSpace(parts[i])
1727
1728 // Make sure we have at least a subject part available
1729 if i+1 >= len(parts) {
1730 break // No more parts available
1731 }
1732
1733 // Get the subject
1734 subject := strings.TrimSpace(parts[i+1])
1735
1736 // Get the body if available
1737 body := ""
1738 if i+2 < len(parts) {
1739 body = strings.TrimSpace(parts[i+2])
1740 }
1741
1742 // Skip to the next triplet
1743 i += 2
1744
1745 commits = append(commits, GitCommit{
1746 Hash: hash,
1747 Subject: subject,
1748 Body: body,
1749 })
1750 }
1751
1752 return commits
1753}
1754
1755func repoRoot(ctx context.Context, dir string) (string, error) {
1756 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1757 stderr := new(strings.Builder)
1758 cmd.Stderr = stderr
1759 cmd.Dir = dir
1760 out, err := cmd.Output()
1761 if err != nil {
1762 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1763 }
1764 return strings.TrimSpace(string(out)), nil
1765}
1766
1767func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1768 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1769 stderr := new(strings.Builder)
1770 cmd.Stderr = stderr
1771 cmd.Dir = dir
1772 out, err := cmd.Output()
1773 if err != nil {
1774 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1775 }
1776 // TODO: validate that out is valid hex
1777 return strings.TrimSpace(string(out)), nil
1778}
1779
1780// isValidGitSHA validates if a string looks like a valid git SHA hash.
1781// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1782func isValidGitSHA(sha string) bool {
1783 // Git SHA must be a hexadecimal string with at least 4 characters
1784 if len(sha) < 4 || len(sha) > 40 {
1785 return false
1786 }
1787
1788 // Check if the string only contains hexadecimal characters
1789 for _, char := range sha {
1790 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1791 return false
1792 }
1793 }
1794
1795 return true
1796}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001797
1798// getGitOrigin returns the URL of the git remote 'origin' if it exists
1799func getGitOrigin(ctx context.Context, dir string) string {
1800 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1801 cmd.Dir = dir
1802 stderr := new(strings.Builder)
1803 cmd.Stderr = stderr
1804 out, err := cmd.Output()
1805 if err != nil {
1806 return ""
1807 }
1808 return strings.TrimSpace(string(out))
1809}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001810
1811func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1812 cmd := exec.CommandContext(ctx, "git", "stash")
1813 cmd.Dir = workingDir
1814 if out, err := cmd.CombinedOutput(); err != nil {
1815 return fmt.Errorf("git stash: %s: %v", out, err)
1816 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001817 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001818 cmd.Dir = workingDir
1819 if out, err := cmd.CombinedOutput(); err != nil {
1820 return fmt.Errorf("git fetch: %s: %w", out, err)
1821 }
1822 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1823 cmd.Dir = workingDir
1824 if out, err := cmd.CombinedOutput(); err != nil {
1825 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1826 }
1827 a.lastHEAD = revision
1828 a.initialCommit = revision
1829 return nil
1830}
1831
1832func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1833 a.mu.Lock()
1834 a.title = ""
1835 a.firstMessageIndex = len(a.history)
1836 a.convo = a.initConvo()
1837 gitReset := func() error {
1838 if a.config.InDocker && rev != "" {
1839 err := a.initGitRevision(ctx, a.workingDir, rev)
1840 if err != nil {
1841 return err
1842 }
1843 } else if !a.config.InDocker && rev != "" {
1844 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1845 }
1846 return nil
1847 }
1848 err := gitReset()
1849 a.mu.Unlock()
1850 if err != nil {
1851 a.pushToOutbox(a.config.Context, errorMessage(err))
1852 }
1853
1854 a.pushToOutbox(a.config.Context, AgentMessage{
1855 Type: AgentMessageType, Content: "Conversation restarted.",
1856 })
1857 if initialPrompt != "" {
1858 a.UserMessage(ctx, initialPrompt)
1859 }
1860 return nil
1861}
1862
1863func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1864 msg := `The user has requested a suggestion for a re-prompt.
1865
1866 Given the current conversation thus far, suggest a re-prompt that would
1867 capture the instructions and feedback so far, as well as any
1868 research or other information that would be helpful in implementing
1869 the task.
1870
1871 Reply with ONLY the reprompt text.
1872 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001873 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001874 // By doing this in a subconversation, the agent doesn't call tools (because
1875 // there aren't any), and there's not a concurrency risk with on-going other
1876 // outstanding conversations.
1877 convo := a.convo.SubConvoWithHistory()
1878 resp, err := convo.SendMessage(userMessage)
1879 if err != nil {
1880 a.pushToOutbox(ctx, errorMessage(err))
1881 return "", err
1882 }
1883 textContent := collectTextContent(resp)
1884 return textContent, nil
1885}
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001886
1887// systemPromptData contains the data used to render the system prompt template
1888type systemPromptData struct {
1889 EditPrompt string
1890 ClientGOOS string
1891 ClientGOARCH string
1892 WorkingDir string
1893 RepoRoot string
1894 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001895 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001896}
1897
1898// renderSystemPrompt renders the system prompt template.
1899func (a *Agent) renderSystemPrompt() string {
1900 // Determine the appropriate edit prompt based on config
1901 var editPrompt string
1902 if a.config.UseAnthropicEdit {
1903 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."
1904 } else {
1905 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1906 }
1907
1908 data := systemPromptData{
1909 EditPrompt: editPrompt,
1910 ClientGOOS: a.config.ClientGOOS,
1911 ClientGOARCH: a.config.ClientGOARCH,
1912 WorkingDir: a.workingDir,
1913 RepoRoot: a.repoRoot,
1914 InitialCommit: a.initialCommit,
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001915 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001916 }
1917
1918 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1919 if err != nil {
1920 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1921 }
1922 buf := new(strings.Builder)
1923 err = tmpl.Execute(buf, data)
1924 if err != nil {
1925 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1926 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001927 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001928 return buf.String()
1929}