blob: a0f981d21555d124175975db40fe1a4776d4b2a6 [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
Philip Zeyligereab12de2025-05-14 02:35:53 +000062 // Returns an iterator that notifies of state transitions until the context is done.
63 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
64
Earl Lee2e463fb2025-04-17 11:22:22 -070065 // Loop begins the agent loop returns only when ctx is cancelled.
66 Loop(ctx context.Context)
67
Sean McCulloughedc88dc2025-04-30 02:55:01 +000068 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070069
70 CancelToolUse(toolUseID string, cause error) error
71
72 // Returns a subset of the agent's message history.
73 Messages(start int, end int) []AgentMessage
74
75 // Returns the current number of messages in the history
76 MessageCount() int
77
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070078 TotalUsage() conversation.CumulativeUsage
79 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070080
Earl Lee2e463fb2025-04-17 11:22:22 -070081 WorkingDir() string
82
83 // Diff returns a unified diff of changes made since the agent was instantiated.
84 // If commit is non-nil, it shows the diff for just that specific commit.
85 Diff(commit *string) (string, error)
86
87 // InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
88 InitialCommit() string
89
90 // Title returns the current title of the conversation.
91 Title() string
92
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000093 // BranchName returns the git branch name for the conversation.
94 BranchName() string
95
Earl Lee2e463fb2025-04-17 11:22:22 -070096 // OS returns the operating system of the client.
97 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +000098
Philip Zeyligerc72fff52025-04-29 20:17:54 +000099 // SessionID returns the unique session identifier.
100 SessionID() string
101
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000102 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
103 OutstandingLLMCallCount() int
104
105 // OutstandingToolCalls returns the names of outstanding tool calls.
106 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000107 OutsideOS() string
108 OutsideHostname() string
109 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000110 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000111 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
112 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700113
114 // RestartConversation resets the conversation history
115 RestartConversation(ctx context.Context, rev string, initialPrompt string) error
116 // SuggestReprompt suggests a re-prompt based on the current conversation.
117 SuggestReprompt(ctx context.Context) (string, error)
118 // IsInContainer returns true if the agent is running in a container
119 IsInContainer() bool
120 // FirstMessageIndex returns the index of the first message in the current conversation
121 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700122
123 CurrentStateName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700124}
125
126type CodingAgentMessageType string
127
128const (
129 UserMessageType CodingAgentMessageType = "user"
130 AgentMessageType CodingAgentMessageType = "agent"
131 ErrorMessageType CodingAgentMessageType = "error"
132 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
133 ToolUseMessageType CodingAgentMessageType = "tool"
134 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
135 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
136
137 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
138)
139
140type AgentMessage struct {
141 Type CodingAgentMessageType `json:"type"`
142 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
143 EndOfTurn bool `json:"end_of_turn"`
144
145 Content string `json:"content"`
146 ToolName string `json:"tool_name,omitempty"`
147 ToolInput string `json:"input,omitempty"`
148 ToolResult string `json:"tool_result,omitempty"`
149 ToolError bool `json:"tool_error,omitempty"`
150 ToolCallId string `json:"tool_call_id,omitempty"`
151
152 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
153 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
154
Sean McCulloughd9f13372025-04-21 15:08:49 -0700155 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
156 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
157
Earl Lee2e463fb2025-04-17 11:22:22 -0700158 // Commits is a list of git commits for a commit message
159 Commits []*GitCommit `json:"commits,omitempty"`
160
161 Timestamp time.Time `json:"timestamp"`
162 ConversationID string `json:"conversation_id"`
163 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700164 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700165
166 // Message timing information
167 StartTime *time.Time `json:"start_time,omitempty"`
168 EndTime *time.Time `json:"end_time,omitempty"`
169 Elapsed *time.Duration `json:"elapsed,omitempty"`
170
171 // Turn duration - the time taken for a complete agent turn
172 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
173
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000174 // HideOutput indicates that this message should not be rendered in the UI.
175 // This is useful for subconversations that generate output that shouldn't be shown to the user.
176 HideOutput bool `json:"hide_output,omitempty"`
177
Earl Lee2e463fb2025-04-17 11:22:22 -0700178 Idx int `json:"idx"`
179}
180
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000181// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700182func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700183 if convo == nil {
184 m.ConversationID = ""
185 m.ParentConversationID = nil
186 return
187 }
188 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000189 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700190 if convo.Parent != nil {
191 m.ParentConversationID = &convo.Parent.ID
192 }
193}
194
Earl Lee2e463fb2025-04-17 11:22:22 -0700195// GitCommit represents a single git commit for a commit message
196type GitCommit struct {
197 Hash string `json:"hash"` // Full commit hash
198 Subject string `json:"subject"` // Commit subject line
199 Body string `json:"body"` // Full commit message body
200 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
201}
202
203// ToolCall represents a single tool call within an agent message
204type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700205 Name string `json:"name"`
206 Input string `json:"input"`
207 ToolCallId string `json:"tool_call_id"`
208 ResultMessage *AgentMessage `json:"result_message,omitempty"`
209 Args string `json:"args,omitempty"`
210 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700211}
212
213func (a *AgentMessage) Attr() slog.Attr {
214 var attrs []any = []any{
215 slog.String("type", string(a.Type)),
216 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700217 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700218 if a.EndOfTurn {
219 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
220 }
221 if a.Content != "" {
222 attrs = append(attrs, slog.String("content", a.Content))
223 }
224 if a.ToolName != "" {
225 attrs = append(attrs, slog.String("tool_name", a.ToolName))
226 }
227 if a.ToolInput != "" {
228 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
229 }
230 if a.Elapsed != nil {
231 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
232 }
233 if a.TurnDuration != nil {
234 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
235 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700236 if len(a.ToolResult) > 0 {
237 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700238 }
239 if a.ToolError {
240 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
241 }
242 if len(a.ToolCalls) > 0 {
243 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
244 for i, tc := range a.ToolCalls {
245 toolCallAttrs = append(toolCallAttrs, slog.Group(
246 fmt.Sprintf("tool_call_%d", i),
247 slog.String("name", tc.Name),
248 slog.String("input", tc.Input),
249 ))
250 }
251 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
252 }
253 if a.ConversationID != "" {
254 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
255 }
256 if a.ParentConversationID != nil {
257 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
258 }
259 if a.Usage != nil && !a.Usage.IsZero() {
260 attrs = append(attrs, a.Usage.Attr())
261 }
262 // TODO: timestamp, convo ids, idx?
263 return slog.Group("agent_message", attrs...)
264}
265
266func errorMessage(err error) AgentMessage {
267 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
268 if os.Getenv(("DEBUG")) == "1" {
269 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
270 }
271
272 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
273}
274
275func budgetMessage(err error) AgentMessage {
276 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
277}
278
279// ConvoInterface defines the interface for conversation interactions
280type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700281 CumulativeUsage() conversation.CumulativeUsage
282 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700283 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700284 SendMessage(message llm.Message) (*llm.Response, error)
285 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700286 GetID() string
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700287 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error)
288 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700289 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700290 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700291}
292
293type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700294 convo ConvoInterface
295 config AgentConfig // config for this agent
296 workingDir string
297 repoRoot string // workingDir may be a subdir of repoRoot
298 url string
299 firstMessageIndex int // index of the first message in the current conversation
300 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
301 initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
302 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000303 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700304 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000305 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700306 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700307 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700308 title string
309 branchName string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000310 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700311 // State machine to track agent state
312 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000313 // Outside information
314 outsideHostname string
315 outsideOS string
316 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000317 // URL of the git remote 'origin' if it exists
318 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700319
320 // Time when the current turn started (reset at the beginning of InnerLoop)
321 startOfTurn time.Time
322
323 // Inbox - for messages from the user to the agent.
324 // sent on by UserMessage
325 // . e.g. when user types into the chat textarea
326 // read from by GatherMessages
327 inbox chan string
328
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000329 // protects cancelTurn
330 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700331 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000332 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700333
334 // protects following
335 mu sync.Mutex
336
337 // Stores all messages for this agent
338 history []AgentMessage
339
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700340 // Iterators add themselves here when they're ready to be notified of new messages.
341 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700342
343 // Track git commits we've already seen (by hash)
344 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000345
346 // Track outstanding LLM call IDs
347 outstandingLLMCalls map[string]struct{}
348
349 // Track outstanding tool calls by ID with their names
350 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700351}
352
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700353// NewIterator implements CodingAgent.
354func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
355 a.mu.Lock()
356 defer a.mu.Unlock()
357
358 return &MessageIteratorImpl{
359 agent: a,
360 ctx: ctx,
361 nextMessageIdx: nextMessageIdx,
362 ch: make(chan *AgentMessage, 100),
363 }
364}
365
366type MessageIteratorImpl struct {
367 agent *Agent
368 ctx context.Context
369 nextMessageIdx int
370 ch chan *AgentMessage
371 subscribed bool
372}
373
374func (m *MessageIteratorImpl) Close() {
375 m.agent.mu.Lock()
376 defer m.agent.mu.Unlock()
377 // Delete ourselves from the subscribers list
378 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
379 return x == m.ch
380 })
381 close(m.ch)
382}
383
384func (m *MessageIteratorImpl) Next() *AgentMessage {
385 // We avoid subscription at creation to let ourselves catch up to "current state"
386 // before subscribing.
387 if !m.subscribed {
388 m.agent.mu.Lock()
389 if m.nextMessageIdx < len(m.agent.history) {
390 msg := &m.agent.history[m.nextMessageIdx]
391 m.nextMessageIdx++
392 m.agent.mu.Unlock()
393 return msg
394 }
395 // The next message doesn't exist yet, so let's subscribe
396 m.agent.subscribers = append(m.agent.subscribers, m.ch)
397 m.subscribed = true
398 m.agent.mu.Unlock()
399 }
400
401 for {
402 select {
403 case <-m.ctx.Done():
404 m.agent.mu.Lock()
405 // Delete ourselves from the subscribers list
406 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
407 return x == m.ch
408 })
409 m.subscribed = false
410 m.agent.mu.Unlock()
411 return nil
412 case msg, ok := <-m.ch:
413 if !ok {
414 // Close may have been called
415 return nil
416 }
417 if msg.Idx == m.nextMessageIdx {
418 m.nextMessageIdx++
419 return msg
420 }
421 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
422 panic("out of order message")
423 }
424 }
425}
426
Sean McCulloughd9d45812025-04-30 16:53:41 -0700427// Assert that Agent satisfies the CodingAgent interface.
428var _ CodingAgent = &Agent{}
429
430// StateName implements CodingAgent.
431func (a *Agent) CurrentStateName() string {
432 if a.stateMachine == nil {
433 return ""
434 }
435 return a.stateMachine.currentState.String()
436}
437
Earl Lee2e463fb2025-04-17 11:22:22 -0700438func (a *Agent) URL() string { return a.url }
439
440// Title returns the current title of the conversation.
441// If no title has been set, returns an empty string.
442func (a *Agent) Title() string {
443 a.mu.Lock()
444 defer a.mu.Unlock()
445 return a.title
446}
447
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000448// BranchName returns the git branch name for the conversation.
449func (a *Agent) BranchName() string {
450 a.mu.Lock()
451 defer a.mu.Unlock()
452 return a.branchName
453}
454
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000455// OutstandingLLMCallCount returns the number of outstanding LLM calls.
456func (a *Agent) OutstandingLLMCallCount() int {
457 a.mu.Lock()
458 defer a.mu.Unlock()
459 return len(a.outstandingLLMCalls)
460}
461
462// OutstandingToolCalls returns the names of outstanding tool calls.
463func (a *Agent) OutstandingToolCalls() []string {
464 a.mu.Lock()
465 defer a.mu.Unlock()
466
467 tools := make([]string, 0, len(a.outstandingToolCalls))
468 for _, toolName := range a.outstandingToolCalls {
469 tools = append(tools, toolName)
470 }
471 return tools
472}
473
Earl Lee2e463fb2025-04-17 11:22:22 -0700474// OS returns the operating system of the client.
475func (a *Agent) OS() string {
476 return a.config.ClientGOOS
477}
478
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000479func (a *Agent) SessionID() string {
480 return a.config.SessionID
481}
482
Philip Zeyliger18532b22025-04-23 21:11:46 +0000483// OutsideOS returns the operating system of the outside system.
484func (a *Agent) OutsideOS() string {
485 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000486}
487
Philip Zeyliger18532b22025-04-23 21:11:46 +0000488// OutsideHostname returns the hostname of the outside system.
489func (a *Agent) OutsideHostname() string {
490 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000491}
492
Philip Zeyliger18532b22025-04-23 21:11:46 +0000493// OutsideWorkingDir returns the working directory on the outside system.
494func (a *Agent) OutsideWorkingDir() string {
495 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000496}
497
498// GitOrigin returns the URL of the git remote 'origin' if it exists.
499func (a *Agent) GitOrigin() string {
500 return a.gitOrigin
501}
502
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000503func (a *Agent) OpenBrowser(url string) {
504 if !a.IsInContainer() {
505 browser.Open(url)
506 return
507 }
508 // We're in Docker, need to send a request to the Git server
509 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700510 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000511 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700512 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000513 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700514 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000515 return
516 }
517 defer resp.Body.Close()
518 if resp.StatusCode == http.StatusOK {
519 return
520 }
521 body, _ := io.ReadAll(resp.Body)
522 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
523}
524
Sean McCullough96b60dd2025-04-30 09:49:10 -0700525// CurrentState returns the current state of the agent's state machine.
526func (a *Agent) CurrentState() State {
527 return a.stateMachine.CurrentState()
528}
529
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700530func (a *Agent) IsInContainer() bool {
531 return a.config.InDocker
532}
533
534func (a *Agent) FirstMessageIndex() int {
535 a.mu.Lock()
536 defer a.mu.Unlock()
537 return a.firstMessageIndex
538}
539
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000540// SetTitle sets the title of the conversation.
541func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700542 a.mu.Lock()
543 defer a.mu.Unlock()
544 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000545}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700546
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000547// SetBranch sets the branch name of the conversation.
548func (a *Agent) SetBranch(branchName string) {
549 a.mu.Lock()
550 defer a.mu.Unlock()
551 a.branchName = branchName
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000552 convo, ok := a.convo.(*conversation.Convo)
553 if ok {
554 convo.ExtraData["branch"] = branchName
555 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700556}
557
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000558// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700559func (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 +0000560 // Track the tool call
561 a.mu.Lock()
562 a.outstandingToolCalls[id] = toolName
563 a.mu.Unlock()
564}
565
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700566// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
567// If there's only one element in the array and it's a text type, it returns that text directly.
568// It also processes nested ToolResult arrays recursively.
569func contentToString(contents []llm.Content) string {
570 if len(contents) == 0 {
571 return ""
572 }
573
574 // If there's only one element and it's a text type, return it directly
575 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
576 return contents[0].Text
577 }
578
579 // Otherwise, concatenate all text content
580 var result strings.Builder
581 for _, content := range contents {
582 if content.Type == llm.ContentTypeText {
583 result.WriteString(content.Text)
584 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
585 // Recursively process nested tool results
586 result.WriteString(contentToString(content.ToolResult))
587 }
588 }
589
590 return result.String()
591}
592
Earl Lee2e463fb2025-04-17 11:22:22 -0700593// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700594func (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 +0000595 // Remove the tool call from outstanding calls
596 a.mu.Lock()
597 delete(a.outstandingToolCalls, toolID)
598 a.mu.Unlock()
599
Earl Lee2e463fb2025-04-17 11:22:22 -0700600 m := AgentMessage{
601 Type: ToolUseMessageType,
602 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700603 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700604 ToolError: content.ToolError,
605 ToolName: toolName,
606 ToolInput: string(toolInput),
607 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700608 StartTime: content.ToolUseStartTime,
609 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700610 }
611
612 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700613 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
614 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700615 m.Elapsed = &elapsed
616 }
617
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700618 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700619 a.pushToOutbox(ctx, m)
620}
621
622// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700623func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000624 a.mu.Lock()
625 defer a.mu.Unlock()
626 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700627 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
628}
629
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700630// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700631// that need to be displayed (as well as tool calls that we send along when
632// they're done). (It would be reasonable to also mention tool calls when they're
633// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700634func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000635 // Remove the LLM call from outstanding calls
636 a.mu.Lock()
637 delete(a.outstandingLLMCalls, id)
638 a.mu.Unlock()
639
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700640 if resp == nil {
641 // LLM API call failed
642 m := AgentMessage{
643 Type: ErrorMessageType,
644 Content: "API call failed, type 'continue' to try again",
645 }
646 m.SetConvo(convo)
647 a.pushToOutbox(ctx, m)
648 return
649 }
650
Earl Lee2e463fb2025-04-17 11:22:22 -0700651 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700652 if convo.Parent == nil { // subconvos never end the turn
653 switch resp.StopReason {
654 case llm.StopReasonToolUse:
655 // Check whether any of the tool calls are for tools that should end the turn
656 ToolSearch:
657 for _, part := range resp.Content {
658 if part.Type != llm.ContentTypeToolUse {
659 continue
660 }
Sean McCullough021557a2025-05-05 23:20:53 +0000661 // Find the tool by name
662 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700663 if tool.Name == part.ToolName {
664 endOfTurn = tool.EndsTurn
665 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000666 }
667 }
Sean McCullough021557a2025-05-05 23:20:53 +0000668 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700669 default:
670 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000671 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700672 }
673 m := AgentMessage{
674 Type: AgentMessageType,
675 Content: collectTextContent(resp),
676 EndOfTurn: endOfTurn,
677 Usage: &resp.Usage,
678 StartTime: resp.StartTime,
679 EndTime: resp.EndTime,
680 }
681
682 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700683 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700684 var toolCalls []ToolCall
685 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700686 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700687 toolCalls = append(toolCalls, ToolCall{
688 Name: part.ToolName,
689 Input: string(part.ToolInput),
690 ToolCallId: part.ID,
691 })
692 }
693 }
694 m.ToolCalls = toolCalls
695 }
696
697 // Calculate the elapsed time if both start and end times are set
698 if resp.StartTime != nil && resp.EndTime != nil {
699 elapsed := resp.EndTime.Sub(*resp.StartTime)
700 m.Elapsed = &elapsed
701 }
702
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700703 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700704 a.pushToOutbox(ctx, m)
705}
706
707// WorkingDir implements CodingAgent.
708func (a *Agent) WorkingDir() string {
709 return a.workingDir
710}
711
712// MessageCount implements CodingAgent.
713func (a *Agent) MessageCount() int {
714 a.mu.Lock()
715 defer a.mu.Unlock()
716 return len(a.history)
717}
718
719// Messages implements CodingAgent.
720func (a *Agent) Messages(start int, end int) []AgentMessage {
721 a.mu.Lock()
722 defer a.mu.Unlock()
723 return slices.Clone(a.history[start:end])
724}
725
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700726func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700727 return a.originalBudget
728}
729
730// AgentConfig contains configuration for creating a new Agent.
731type AgentConfig struct {
732 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700733 Service llm.Service
734 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700735 GitUsername string
736 GitEmail string
737 SessionID string
738 ClientGOOS string
739 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700740 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700741 UseAnthropicEdit bool
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000742 OneShot bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000743 // Outside information
744 OutsideHostname string
745 OutsideOS string
746 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700747}
748
749// NewAgent creates a new Agent.
750// It is not usable until Init() is called.
751func NewAgent(config AgentConfig) *Agent {
752 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000753 config: config,
754 ready: make(chan struct{}),
755 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700756 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000757 startedAt: time.Now(),
758 originalBudget: config.Budget,
759 seenCommits: make(map[string]bool),
760 outsideHostname: config.OutsideHostname,
761 outsideOS: config.OutsideOS,
762 outsideWorkingDir: config.OutsideWorkingDir,
763 outstandingLLMCalls: make(map[string]struct{}),
764 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700765 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700766 }
767 return agent
768}
769
770type AgentInit struct {
771 WorkingDir string
772 NoGit bool // only for testing
773
774 InDocker bool
775 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000776 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700777 GitRemoteAddr string
778 HostAddr string
779}
780
781func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700782 if a.convo != nil {
783 return fmt.Errorf("Agent.Init: already initialized")
784 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700785 ctx := a.config.Context
786 if ini.InDocker {
787 cmd := exec.CommandContext(ctx, "git", "stash")
788 cmd.Dir = ini.WorkingDir
789 if out, err := cmd.CombinedOutput(); err != nil {
790 return fmt.Errorf("git stash: %s: %v", out, err)
791 }
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700792 // sketch-host is a git repo hosted by "outtie sketch". When it notices a 'git fetch',
793 // it runs "git fetch" underneath the covers to get its latest commits. By configuring
794 // an additional remote.sketch-host.fetch, we make "origin/main" on innie sketch look like
795 // origin/main on outtie sketch, which should make it easier to rebase.
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700796 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
797 cmd.Dir = ini.WorkingDir
798 if out, err := cmd.CombinedOutput(); err != nil {
799 return fmt.Errorf("git remote add: %s: %v", out, err)
800 }
Philip Zeyligere97a8e52025-05-09 14:53:33 -0700801 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.sketch-host.fetch",
802 "+refs/heads/feature/*:refs/remotes/origin/feature/*")
803 cmd.Dir = ini.WorkingDir
804 if out, err := cmd.CombinedOutput(); err != nil {
805 return fmt.Errorf("git config --add: %s: %v", out, err)
806 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000807 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700808 cmd.Dir = ini.WorkingDir
809 if out, err := cmd.CombinedOutput(); err != nil {
810 return fmt.Errorf("git fetch: %s: %w", out, err)
811 }
812 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
813 cmd.Dir = ini.WorkingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100814 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
815 // Remove git hooks if they exist and retry
816 // Only try removing hooks if we haven't already removed them during fetch
817 hookPath := filepath.Join(ini.WorkingDir, ".git", "hooks")
818 if _, statErr := os.Stat(hookPath); statErr == nil {
819 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
820 slog.String("error", err.Error()),
821 slog.String("output", string(checkoutOut)))
822 if removeErr := removeGitHooks(ctx, ini.WorkingDir); removeErr != nil {
823 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
824 }
825
826 // Retry the checkout operation
827 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
828 cmd.Dir = ini.WorkingDir
829 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
830 return fmt.Errorf("git checkout %s failed even after removing hooks: %s: %w", ini.Commit, retryOut, retryErr)
831 }
832 } else {
833 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, checkoutOut, err)
834 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700835 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700836 a.lastHEAD = ini.Commit
837 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000838 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700839 a.initialCommit = ini.Commit
840 if ini.HostAddr != "" {
841 a.url = "http://" + ini.HostAddr
842 }
843 }
844 a.workingDir = ini.WorkingDir
845
846 if !ini.NoGit {
847 repoRoot, err := repoRoot(ctx, a.workingDir)
848 if err != nil {
849 return fmt.Errorf("repoRoot: %w", err)
850 }
851 a.repoRoot = repoRoot
852
853 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
854 if err != nil {
855 return fmt.Errorf("resolveRef: %w", err)
856 }
857 a.initialCommit = commitHash
858
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000859 if experiment.Enabled("memory") {
860 slog.Info("running codebase analysis")
861 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
862 if err != nil {
863 slog.Warn("failed to analyze codebase", "error", err)
864 }
865 a.codebase = codebase
866 }
867
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000868 llmCodeReview := codereview.NoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700869 if experiment.Enabled("llm_review") {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000870 llmCodeReview = codereview.DoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700871 }
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000872 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit, llmCodeReview)
Earl Lee2e463fb2025-04-17 11:22:22 -0700873 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000874 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700875 }
876 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000877
878 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700879 }
880 a.lastHEAD = a.initialCommit
881 a.convo = a.initConvo()
882 close(a.ready)
883 return nil
884}
885
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700886//go:embed agent_system_prompt.txt
887var agentSystemPrompt string
888
Earl Lee2e463fb2025-04-17 11:22:22 -0700889// initConvo initializes the conversation.
890// It must not be called until all agent fields are initialized,
891// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700892func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700893 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700894 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700895 convo.PromptCaching = true
896 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000897 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000898 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -0700899
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000900 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
901 bashPermissionCheck := func(command string) error {
902 // Check if branch name is set
903 a.mu.Lock()
904 branchSet := a.branchName != ""
905 a.mu.Unlock()
906
907 // If branch is set, all commands are allowed
908 if branchSet {
909 return nil
910 }
911
912 // If branch is not set, check if this is a git commit command
913 willCommit, err := bashkit.WillRunGitCommit(command)
914 if err != nil {
915 // If there's an error checking, we should allow the command to proceed
916 return nil
917 }
918
919 // If it's a git commit and branch is not set, return an error
920 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000921 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000922 }
923
924 return nil
925 }
926
927 // Create a custom bash tool with the permission check
928 bashTool := claudetool.NewBashTool(bashPermissionCheck)
929
Earl Lee2e463fb2025-04-17 11:22:22 -0700930 // Register all tools with the conversation
931 // When adding, removing, or modifying tools here, double-check that the termui tool display
932 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000933
934 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -0700935 _, supportsScreenshots := a.config.Service.(*ant.Service)
936 var bTools []*llm.Tool
937 var browserCleanup func()
938
939 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
940 // Add cleanup function to context cancel
941 go func() {
942 <-a.config.Context.Done()
943 browserCleanup()
944 }()
945 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000946
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700947 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000948 bashTool, claudetool.Keyword,
Josh Bleecher Snyder93202652025-05-08 02:05:57 +0000949 claudetool.Think, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000950 a.codereview.Tool(),
951 }
952
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000953 if experiment.Enabled("kb") {
954 convo.Tools = append(convo.Tools, claudetool.KnowledgeBase)
955 }
956
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000957 // One-shot mode is non-interactive, multiple choice requires human response
958 if !a.config.OneShot {
959 convo.Tools = append(convo.Tools, a.multipleChoiceTool())
Earl Lee2e463fb2025-04-17 11:22:22 -0700960 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000961
962 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -0700963 if a.config.UseAnthropicEdit {
964 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
965 } else {
966 convo.Tools = append(convo.Tools, claudetool.Patch)
967 }
968 convo.Listener = a
969 return convo
970}
971
Sean McCullough485afc62025-04-28 14:28:39 -0700972func (a *Agent) multipleChoiceTool() *llm.Tool {
973 ret := &llm.Tool{
974 Name: "multiplechoice",
975 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 +0000976 EndsTurn: true,
Sean McCullough485afc62025-04-28 14:28:39 -0700977 InputSchema: json.RawMessage(`{
978 "type": "object",
979 "description": "The question and a list of answers you would expect the user to choose from.",
980 "properties": {
981 "question": {
982 "type": "string",
983 "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?'"
984 },
985 "responseOptions": {
986 "type": "array",
987 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
988 "items": {
989 "type": "object",
990 "properties": {
991 "caption": {
992 "type": "string",
993 "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'"
994 },
995 "responseText": {
996 "type": "string",
997 "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'"
998 }
999 },
1000 "required": ["caption", "responseText"]
1001 }
1002 }
1003 },
1004 "required": ["question", "responseOptions"]
1005}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001006 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Sean McCullough485afc62025-04-28 14:28:39 -07001007 // The Run logic for "multiplchoice" tool is a no-op on the server.
1008 // The UI will present a list of options for the user to select from,
1009 // and that's it as far as "executing" the tool_use goes.
1010 // When the user *does* select one of the presented options, that
1011 // responseText gets sent as a chat message on behalf of the user.
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001012 return llm.TextContent("end your turn and wait for the user to respond"), nil
Sean McCullough485afc62025-04-28 14:28:39 -07001013 },
1014 }
1015 return ret
1016}
1017
1018type MultipleChoiceOption struct {
1019 Caption string `json:"caption"`
1020 ResponseText string `json:"responseText"`
1021}
1022
1023type MultipleChoiceParams struct {
1024 Question string `json:"question"`
1025 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1026}
1027
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001028// branchExists reports whether branchName exists, either locally or in well-known remotes.
1029func branchExists(dir, branchName string) bool {
1030 refs := []string{
1031 "refs/heads/",
1032 "refs/remotes/origin/",
1033 "refs/remotes/sketch-host/",
1034 }
1035 for _, ref := range refs {
1036 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1037 cmd.Dir = dir
1038 if cmd.Run() == nil { // exit code 0 means branch exists
1039 return true
1040 }
1041 }
1042 return false
1043}
1044
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001045func (a *Agent) titleTool() *llm.Tool {
1046 description := `Sets the conversation title.`
1047 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001048 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001049 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001050 InputSchema: json.RawMessage(`{
1051 "type": "object",
1052 "properties": {
1053 "title": {
1054 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001055 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001056 }
1057 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001058 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001059}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001060 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001061 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001062 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001063 }
1064 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001065 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001066 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001067
1068 // We don't allow changing the title once set to be consistent with the previous behavior
1069 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001070 t := a.Title()
1071 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001072 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001073 }
1074
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001075 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001076 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001077 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001078
1079 a.SetTitle(params.Title)
1080 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001081 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001082 },
1083 }
1084 return titleTool
1085}
1086
1087func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001088 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 +00001089 preCommit := &llm.Tool{
1090 Name: "precommit",
1091 Description: description,
1092 InputSchema: json.RawMessage(`{
1093 "type": "object",
1094 "properties": {
1095 "branch_name": {
1096 "type": "string",
1097 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1098 }
1099 },
1100 "required": ["branch_name"]
1101}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001102 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001103 var params struct {
1104 BranchName string `json:"branch_name"`
1105 }
1106 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001107 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001108 }
1109
1110 b := a.BranchName()
1111 if b != "" {
Josh Bleecher Snyder44d1f1a2025-05-12 19:18:32 -07001112 return nil, fmt.Errorf("branch already set to %s; do not create a new branch", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001113 }
1114
1115 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001116 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001117 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001118 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001119 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001120 }
1121 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001122 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001123 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001124 }
1125
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001126 a.SetBranch(branchName)
1127 response := fmt.Sprintf("Branch name set to %q", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001128
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001129 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1130 if err != nil {
1131 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1132 }
1133 if len(styleHint) > 0 {
1134 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001135 }
1136
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001137 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001138 },
1139 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001140 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001141}
1142
1143func (a *Agent) Ready() <-chan struct{} {
1144 return a.ready
1145}
1146
1147func (a *Agent) UserMessage(ctx context.Context, msg string) {
1148 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1149 a.inbox <- msg
1150}
1151
Earl Lee2e463fb2025-04-17 11:22:22 -07001152func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1153 return a.convo.CancelToolUse(toolUseID, cause)
1154}
1155
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001156func (a *Agent) CancelTurn(cause error) {
1157 a.cancelTurnMu.Lock()
1158 defer a.cancelTurnMu.Unlock()
1159 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001160 // Force state transition to cancelled state
1161 ctx := a.config.Context
1162 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001163 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001164 }
1165}
1166
1167func (a *Agent) Loop(ctxOuter context.Context) {
1168 for {
1169 select {
1170 case <-ctxOuter.Done():
1171 return
1172 default:
1173 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001174 a.cancelTurnMu.Lock()
1175 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001176 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001177 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001178 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001179 a.cancelTurn = cancel
1180 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001181 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1182 if err != nil {
1183 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1184 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001185 cancel(nil)
1186 }
1187 }
1188}
1189
1190func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1191 if m.Timestamp.IsZero() {
1192 m.Timestamp = time.Now()
1193 }
1194
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001195 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1196 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1197 m.Content = m.ToolResult
1198 }
1199
Earl Lee2e463fb2025-04-17 11:22:22 -07001200 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1201 if m.EndOfTurn && m.Type == AgentMessageType {
1202 turnDuration := time.Since(a.startOfTurn)
1203 m.TurnDuration = &turnDuration
1204 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1205 }
1206
Earl Lee2e463fb2025-04-17 11:22:22 -07001207 a.mu.Lock()
1208 defer a.mu.Unlock()
1209 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001210 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001211 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001212
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001213 // Notify all subscribers
1214 for _, ch := range a.subscribers {
1215 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001216 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001217}
1218
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001219func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1220 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001221 if block {
1222 select {
1223 case <-ctx.Done():
1224 return m, ctx.Err()
1225 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001226 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001227 }
1228 }
1229 for {
1230 select {
1231 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001232 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001233 default:
1234 return m, nil
1235 }
1236 }
1237}
1238
Sean McCullough885a16a2025-04-30 02:49:25 +00001239// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001240func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001241 // Reset the start of turn time
1242 a.startOfTurn = time.Now()
1243
Sean McCullough96b60dd2025-04-30 09:49:10 -07001244 // Transition to waiting for user input state
1245 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1246
Sean McCullough885a16a2025-04-30 02:49:25 +00001247 // Process initial user message
1248 initialResp, err := a.processUserMessage(ctx)
1249 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001250 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001251 return err
1252 }
1253
1254 // Handle edge case where both initialResp and err are nil
1255 if initialResp == nil {
1256 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001257 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1258
Sean McCullough9f4b8082025-04-30 17:34:07 +00001259 a.pushToOutbox(ctx, errorMessage(err))
1260 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001261 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001262
Earl Lee2e463fb2025-04-17 11:22:22 -07001263 // We do this as we go, but let's also do it at the end of the turn
1264 defer func() {
1265 if _, err := a.handleGitCommits(ctx); err != nil {
1266 // Just log the error, don't stop execution
1267 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1268 }
1269 }()
1270
Sean McCullougha1e0e492025-05-01 10:51:08 -07001271 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001272 resp := initialResp
1273 for {
1274 // Check if we are over budget
1275 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001276 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001277 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001278 }
1279
1280 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001281 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001282 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001283 break
1284 }
1285
Sean McCullough96b60dd2025-04-30 09:49:10 -07001286 // Transition to tool use requested state
1287 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1288
Sean McCullough885a16a2025-04-30 02:49:25 +00001289 // Handle tool execution
1290 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1291 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001292 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001293 }
1294
Sean McCullougha1e0e492025-05-01 10:51:08 -07001295 if toolResp == nil {
1296 return fmt.Errorf("cannot continue conversation with a nil tool response")
1297 }
1298
Sean McCullough885a16a2025-04-30 02:49:25 +00001299 // Set the response for the next iteration
1300 resp = toolResp
1301 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001302
1303 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001304}
1305
1306// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001307func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001308 // Wait for at least one message from the user
1309 msgs, err := a.GatherMessages(ctx, true)
1310 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001311 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001312 return nil, err
1313 }
1314
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001315 userMessage := llm.Message{
1316 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001317 Content: msgs,
1318 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001319
Sean McCullough96b60dd2025-04-30 09:49:10 -07001320 // Transition to sending to LLM state
1321 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1322
Sean McCullough885a16a2025-04-30 02:49:25 +00001323 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001324 resp, err := a.convo.SendMessage(userMessage)
1325 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001326 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001327 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001328 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001329 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001330
Sean McCullough96b60dd2025-04-30 09:49:10 -07001331 // Transition to processing LLM response state
1332 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1333
Sean McCullough885a16a2025-04-30 02:49:25 +00001334 return resp, nil
1335}
1336
1337// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001338func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1339 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001340 cancelled := false
1341
Sean McCullough96b60dd2025-04-30 09:49:10 -07001342 // Transition to checking for cancellation state
1343 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1344
Sean McCullough885a16a2025-04-30 02:49:25 +00001345 // Check if the operation was cancelled by the user
1346 select {
1347 case <-ctx.Done():
1348 // Don't actually run any of the tools, but rather build a response
1349 // for each tool_use message letting the LLM know that user canceled it.
1350 var err error
1351 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001352 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001353 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001354 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001355 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001356 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001357 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001358 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001359 // Transition to running tool state
1360 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1361
Sean McCullough885a16a2025-04-30 02:49:25 +00001362 // Add working directory to context for tool execution
1363 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1364
1365 // Execute the tools
1366 var err error
1367 results, err = a.convo.ToolResultContents(ctx, resp)
1368 if ctx.Err() != nil { // e.g. the user canceled the operation
1369 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001370 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001371 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001372 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001373 a.pushToOutbox(ctx, errorMessage(err))
1374 }
1375 }
1376
1377 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001378 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001379 autoqualityMessages := a.processGitChanges(ctx)
1380
1381 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001382 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001383 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001384 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001385 return false, nil
1386 }
1387
1388 // Continue the conversation with tool results and any user messages
1389 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1390}
1391
1392// processGitChanges checks for new git commits and runs autoformatters if needed
1393func (a *Agent) processGitChanges(ctx context.Context) []string {
1394 // Check for git commits after tool execution
1395 newCommits, err := a.handleGitCommits(ctx)
1396 if err != nil {
1397 // Just log the error, don't stop execution
1398 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1399 return nil
1400 }
1401
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001402 // Run mechanical checks if there was exactly one new commit.
1403 if len(newCommits) != 1 {
1404 return nil
1405 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001406 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001407 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1408 msg := a.codereview.RunMechanicalChecks(ctx)
1409 if msg != "" {
1410 a.pushToOutbox(ctx, AgentMessage{
1411 Type: AutoMessageType,
1412 Content: msg,
1413 Timestamp: time.Now(),
1414 })
1415 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001416 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001417
1418 return autoqualityMessages
1419}
1420
1421// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001422func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001423 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001424 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001425 msgs, err := a.GatherMessages(ctx, false)
1426 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001427 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001428 return false, nil
1429 }
1430
1431 // Inject any auto-generated messages from quality checks
1432 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001433 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001434 }
1435
1436 // Handle cancellation by appending a message about it
1437 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001438 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001439 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001440 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001441 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1442 } else if err := a.convo.OverBudget(); err != nil {
1443 // Handle budget issues by appending a message about it
1444 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 -07001445 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001446 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1447 }
1448
1449 // Combine tool results with user messages
1450 results = append(results, msgs...)
1451
1452 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001453 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001454 resp, err := a.convo.SendMessage(llm.Message{
1455 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001456 Content: results,
1457 })
1458 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001459 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001460 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1461 return true, nil // Return true to continue the conversation, but with no response
1462 }
1463
Sean McCullough96b60dd2025-04-30 09:49:10 -07001464 // Transition back to processing LLM response
1465 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1466
Sean McCullough885a16a2025-04-30 02:49:25 +00001467 if cancelled {
1468 return false, nil
1469 }
1470
1471 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001472}
1473
1474func (a *Agent) overBudget(ctx context.Context) error {
1475 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001476 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001477 m := budgetMessage(err)
1478 m.Content = m.Content + "\n\nBudget reset."
1479 a.pushToOutbox(ctx, budgetMessage(err))
1480 a.convo.ResetBudget(a.originalBudget)
1481 return err
1482 }
1483 return nil
1484}
1485
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001486func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001487 // Collect all text content
1488 var allText strings.Builder
1489 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001490 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001491 if allText.Len() > 0 {
1492 allText.WriteString("\n\n")
1493 }
1494 allText.WriteString(content.Text)
1495 }
1496 }
1497 return allText.String()
1498}
1499
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001500func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001501 a.mu.Lock()
1502 defer a.mu.Unlock()
1503 return a.convo.CumulativeUsage()
1504}
1505
Earl Lee2e463fb2025-04-17 11:22:22 -07001506// Diff returns a unified diff of changes made since the agent was instantiated.
1507func (a *Agent) Diff(commit *string) (string, error) {
1508 if a.initialCommit == "" {
1509 return "", fmt.Errorf("no initial commit reference available")
1510 }
1511
1512 // Find the repository root
1513 ctx := context.Background()
1514
1515 // If a specific commit hash is provided, show just that commit's changes
1516 if commit != nil && *commit != "" {
1517 // Validate that the commit looks like a valid git SHA
1518 if !isValidGitSHA(*commit) {
1519 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1520 }
1521
1522 // Get the diff for just this commit
1523 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1524 cmd.Dir = a.repoRoot
1525 output, err := cmd.CombinedOutput()
1526 if err != nil {
1527 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1528 }
1529 return string(output), nil
1530 }
1531
1532 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1533 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1534 cmd.Dir = a.repoRoot
1535 output, err := cmd.CombinedOutput()
1536 if err != nil {
1537 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1538 }
1539
1540 return string(output), nil
1541}
1542
1543// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1544func (a *Agent) InitialCommit() string {
1545 return a.initialCommit
1546}
1547
Pokey Rule7a113622025-05-12 10:58:45 +01001548// removeGitHooks removes the Git hooks directory from the repository
1549func removeGitHooks(_ context.Context, repoPath string) error {
1550 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1551
1552 // Check if hooks directory exists
1553 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1554 // Directory doesn't exist, nothing to do
1555 return nil
1556 }
1557
1558 // Remove the hooks directory
1559 err := os.RemoveAll(hooksDir)
1560 if err != nil {
1561 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1562 }
1563
1564 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001565 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001566 if err != nil {
1567 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1568 }
1569
1570 return nil
1571}
1572
Earl Lee2e463fb2025-04-17 11:22:22 -07001573// handleGitCommits() highlights new commits to the user. When running
1574// under docker, new HEADs are pushed to a branch according to the title.
1575func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1576 if a.repoRoot == "" {
1577 return nil, nil
1578 }
1579
1580 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1581 if err != nil {
1582 return nil, err
1583 }
1584 if head == a.lastHEAD {
1585 return nil, nil // nothing to do
1586 }
1587 defer func() {
1588 a.lastHEAD = head
1589 }()
1590
1591 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1592 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1593 // to the last 100 commits.
1594 var commits []*GitCommit
1595
1596 // Get commits since the initial commit
1597 // Format: <hash>\0<subject>\0<body>\0
1598 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1599 // Limit to 100 commits to avoid overwhelming the user
1600 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1601 cmd.Dir = a.repoRoot
1602 output, err := cmd.Output()
1603 if err != nil {
1604 return nil, fmt.Errorf("failed to get git log: %w", err)
1605 }
1606
1607 // Parse git log output and filter out already seen commits
1608 parsedCommits := parseGitLog(string(output))
1609
1610 var headCommit *GitCommit
1611
1612 // Filter out commits we've already seen
1613 for _, commit := range parsedCommits {
1614 if commit.Hash == head {
1615 headCommit = &commit
1616 }
1617
1618 // Skip if we've seen this commit before. If our head has changed, always include that.
1619 if a.seenCommits[commit.Hash] && commit.Hash != head {
1620 continue
1621 }
1622
1623 // Mark this commit as seen
1624 a.seenCommits[commit.Hash] = true
1625
1626 // Add to our list of new commits
1627 commits = append(commits, &commit)
1628 }
1629
1630 if a.gitRemoteAddr != "" {
1631 if headCommit == nil {
1632 // I think this can only happen if we have a bug or if there's a race.
1633 headCommit = &GitCommit{}
1634 headCommit.Hash = head
1635 headCommit.Subject = "unknown"
1636 commits = append(commits, headCommit)
1637 }
1638
Philip Zeyliger113e2052025-05-09 21:59:40 +00001639 originalBranch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
1640 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001641
1642 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1643 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1644 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001645
1646 // Try up to 10 times with different branch names if the branch is checked out on the remote
1647 var out []byte
1648 var err error
1649 for retries := range 10 {
1650 if retries > 0 {
1651 // Add a numeric suffix to the branch name
1652 branch = fmt.Sprintf("%s%d", originalBranch, retries)
1653 }
1654
1655 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1656 cmd.Dir = a.workingDir
1657 out, err = cmd.CombinedOutput()
1658
1659 if err == nil {
1660 // Success! Break out of the retry loop
1661 break
1662 }
1663
1664 // Check if this is the "refusing to update checked out branch" error
1665 if !strings.Contains(string(out), "refusing to update checked out branch") {
1666 // This is a different error, so don't retry
1667 break
1668 }
1669
1670 // If we're on the last retry, we'll report the error
1671 if retries == 9 {
1672 break
1673 }
1674 }
1675
1676 if err != nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001677 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1678 } else {
1679 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001680 // Update the agent's branch name if we ended up using a different one
1681 if branch != originalBranch {
1682 a.branchName = branch
1683 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001684 }
1685 }
1686
1687 // If we found new commits, create a message
1688 if len(commits) > 0 {
1689 msg := AgentMessage{
1690 Type: CommitMessageType,
1691 Timestamp: time.Now(),
1692 Commits: commits,
1693 }
1694 a.pushToOutbox(ctx, msg)
1695 }
1696 return commits, nil
1697}
1698
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001699func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001700 return strings.Map(func(r rune) rune {
1701 // lowercase
1702 if r >= 'A' && r <= 'Z' {
1703 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001704 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001705 // replace spaces with dashes
1706 if r == ' ' {
1707 return '-'
1708 }
1709 // allow alphanumerics and dashes
1710 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1711 return r
1712 }
1713 return -1
1714 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001715}
1716
1717// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1718// and returns an array of GitCommit structs.
1719func parseGitLog(output string) []GitCommit {
1720 var commits []GitCommit
1721
1722 // No output means no commits
1723 if len(output) == 0 {
1724 return commits
1725 }
1726
1727 // Split by NULL byte
1728 parts := strings.Split(output, "\x00")
1729
1730 // Process in triplets (hash, subject, body)
1731 for i := 0; i < len(parts); i++ {
1732 // Skip empty parts
1733 if parts[i] == "" {
1734 continue
1735 }
1736
1737 // This should be a hash
1738 hash := strings.TrimSpace(parts[i])
1739
1740 // Make sure we have at least a subject part available
1741 if i+1 >= len(parts) {
1742 break // No more parts available
1743 }
1744
1745 // Get the subject
1746 subject := strings.TrimSpace(parts[i+1])
1747
1748 // Get the body if available
1749 body := ""
1750 if i+2 < len(parts) {
1751 body = strings.TrimSpace(parts[i+2])
1752 }
1753
1754 // Skip to the next triplet
1755 i += 2
1756
1757 commits = append(commits, GitCommit{
1758 Hash: hash,
1759 Subject: subject,
1760 Body: body,
1761 })
1762 }
1763
1764 return commits
1765}
1766
1767func repoRoot(ctx context.Context, dir string) (string, error) {
1768 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
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 return strings.TrimSpace(string(out)), nil
1777}
1778
1779func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1780 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1781 stderr := new(strings.Builder)
1782 cmd.Stderr = stderr
1783 cmd.Dir = dir
1784 out, err := cmd.Output()
1785 if err != nil {
1786 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1787 }
1788 // TODO: validate that out is valid hex
1789 return strings.TrimSpace(string(out)), nil
1790}
1791
1792// isValidGitSHA validates if a string looks like a valid git SHA hash.
1793// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1794func isValidGitSHA(sha string) bool {
1795 // Git SHA must be a hexadecimal string with at least 4 characters
1796 if len(sha) < 4 || len(sha) > 40 {
1797 return false
1798 }
1799
1800 // Check if the string only contains hexadecimal characters
1801 for _, char := range sha {
1802 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1803 return false
1804 }
1805 }
1806
1807 return true
1808}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001809
1810// getGitOrigin returns the URL of the git remote 'origin' if it exists
1811func getGitOrigin(ctx context.Context, dir string) string {
1812 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1813 cmd.Dir = dir
1814 stderr := new(strings.Builder)
1815 cmd.Stderr = stderr
1816 out, err := cmd.Output()
1817 if err != nil {
1818 return ""
1819 }
1820 return strings.TrimSpace(string(out))
1821}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001822
1823func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1824 cmd := exec.CommandContext(ctx, "git", "stash")
1825 cmd.Dir = workingDir
1826 if out, err := cmd.CombinedOutput(); err != nil {
1827 return fmt.Errorf("git stash: %s: %v", out, err)
1828 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001829 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001830 cmd.Dir = workingDir
1831 if out, err := cmd.CombinedOutput(); err != nil {
1832 return fmt.Errorf("git fetch: %s: %w", out, err)
1833 }
1834 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1835 cmd.Dir = workingDir
1836 if out, err := cmd.CombinedOutput(); err != nil {
1837 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1838 }
1839 a.lastHEAD = revision
1840 a.initialCommit = revision
1841 return nil
1842}
1843
1844func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1845 a.mu.Lock()
1846 a.title = ""
1847 a.firstMessageIndex = len(a.history)
1848 a.convo = a.initConvo()
1849 gitReset := func() error {
1850 if a.config.InDocker && rev != "" {
1851 err := a.initGitRevision(ctx, a.workingDir, rev)
1852 if err != nil {
1853 return err
1854 }
1855 } else if !a.config.InDocker && rev != "" {
1856 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1857 }
1858 return nil
1859 }
1860 err := gitReset()
1861 a.mu.Unlock()
1862 if err != nil {
1863 a.pushToOutbox(a.config.Context, errorMessage(err))
1864 }
1865
1866 a.pushToOutbox(a.config.Context, AgentMessage{
1867 Type: AgentMessageType, Content: "Conversation restarted.",
1868 })
1869 if initialPrompt != "" {
1870 a.UserMessage(ctx, initialPrompt)
1871 }
1872 return nil
1873}
1874
1875func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1876 msg := `The user has requested a suggestion for a re-prompt.
1877
1878 Given the current conversation thus far, suggest a re-prompt that would
1879 capture the instructions and feedback so far, as well as any
1880 research or other information that would be helpful in implementing
1881 the task.
1882
1883 Reply with ONLY the reprompt text.
1884 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001885 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001886 // By doing this in a subconversation, the agent doesn't call tools (because
1887 // there aren't any), and there's not a concurrency risk with on-going other
1888 // outstanding conversations.
1889 convo := a.convo.SubConvoWithHistory()
1890 resp, err := convo.SendMessage(userMessage)
1891 if err != nil {
1892 a.pushToOutbox(ctx, errorMessage(err))
1893 return "", err
1894 }
1895 textContent := collectTextContent(resp)
1896 return textContent, nil
1897}
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001898
1899// systemPromptData contains the data used to render the system prompt template
1900type systemPromptData struct {
1901 EditPrompt string
1902 ClientGOOS string
1903 ClientGOARCH string
1904 WorkingDir string
1905 RepoRoot string
1906 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001907 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001908}
1909
1910// renderSystemPrompt renders the system prompt template.
1911func (a *Agent) renderSystemPrompt() string {
1912 // Determine the appropriate edit prompt based on config
1913 var editPrompt string
1914 if a.config.UseAnthropicEdit {
1915 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."
1916 } else {
1917 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1918 }
1919
1920 data := systemPromptData{
1921 EditPrompt: editPrompt,
1922 ClientGOOS: a.config.ClientGOOS,
1923 ClientGOARCH: a.config.ClientGOARCH,
1924 WorkingDir: a.workingDir,
1925 RepoRoot: a.repoRoot,
1926 InitialCommit: a.initialCommit,
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001927 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001928 }
1929
1930 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1931 if err != nil {
1932 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1933 }
1934 buf := new(strings.Builder)
1935 err = tmpl.Execute(buf, data)
1936 if err != nil {
1937 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1938 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001939 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001940 return buf.String()
1941}
Philip Zeyligereab12de2025-05-14 02:35:53 +00001942
1943// StateTransitionIterator provides an iterator over state transitions.
1944type StateTransitionIterator interface {
1945 // Next blocks until a new state transition is available or context is done.
1946 // Returns nil if the context is cancelled.
1947 Next() *StateTransition
1948 // Close removes the listener and cleans up resources.
1949 Close()
1950}
1951
1952// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
1953type StateTransitionIteratorImpl struct {
1954 agent *Agent
1955 ctx context.Context
1956 ch chan StateTransition
1957 unsubscribe func()
1958}
1959
1960// Next blocks until a new state transition is available or the context is cancelled.
1961func (s *StateTransitionIteratorImpl) Next() *StateTransition {
1962 select {
1963 case <-s.ctx.Done():
1964 return nil
1965 case transition, ok := <-s.ch:
1966 if !ok {
1967 return nil
1968 }
1969 transitionCopy := transition
1970 return &transitionCopy
1971 }
1972}
1973
1974// Close removes the listener and cleans up resources.
1975func (s *StateTransitionIteratorImpl) Close() {
1976 if s.unsubscribe != nil {
1977 s.unsubscribe()
1978 s.unsubscribe = nil
1979 }
1980}
1981
1982// NewStateTransitionIterator returns an iterator that receives state transitions.
1983func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
1984 a.mu.Lock()
1985 defer a.mu.Unlock()
1986
1987 // Create channel to receive state transitions
1988 ch := make(chan StateTransition, 10)
1989
1990 // Add a listener to the state machine
1991 unsubscribe := a.stateMachine.AddTransitionListener(ch)
1992
1993 return &StateTransitionIteratorImpl{
1994 agent: a,
1995 ctx: ctx,
1996 ch: ch,
1997 unsubscribe: unsubscribe,
1998 }
1999}