blob: 0f08d36255e56c86b00708091ca35ee2d3f91db5 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package loop
2
3import (
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07004 "cmp"
Earl Lee2e463fb2025-04-17 11:22:22 -07005 "context"
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07006 _ "embed"
Earl Lee2e463fb2025-04-17 11:22:22 -07007 "encoding/json"
8 "fmt"
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +00009 "io"
Earl Lee2e463fb2025-04-17 11:22:22 -070010 "log/slog"
11 "net/http"
12 "os"
13 "os/exec"
14 "runtime/debug"
15 "slices"
16 "strings"
17 "sync"
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +000018 "text/template"
Earl Lee2e463fb2025-04-17 11:22:22 -070019 "time"
20
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000021 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070022 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000023 "sketch.dev/claudetool/bashkit"
Autoformatter4962f152025-05-06 17:24:20 +000024 "sketch.dev/claudetool/browse"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000025 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -070026 "sketch.dev/experiment"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070027 "sketch.dev/llm"
28 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070029)
30
31const (
32 userCancelMessage = "user requested agent to stop handling responses"
33)
34
Philip Zeyligerb7c58752025-05-01 10:10:17 -070035type MessageIterator interface {
36 // Next blocks until the next message is available. It may
37 // return nil if the underlying iterator context is done.
38 Next() *AgentMessage
39 Close()
40}
41
Earl Lee2e463fb2025-04-17 11:22:22 -070042type CodingAgent interface {
43 // Init initializes an agent inside a docker container.
44 Init(AgentInit) error
45
46 // Ready returns a channel closed after Init successfully called.
47 Ready() <-chan struct{}
48
49 // URL reports the HTTP URL of this agent.
50 URL() string
51
52 // UserMessage enqueues a message to the agent and returns immediately.
53 UserMessage(ctx context.Context, msg string)
54
Philip Zeyligerb7c58752025-05-01 10:10:17 -070055 // Returns an iterator that finishes when the context is done and
56 // starts with the given message index.
57 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070058
59 // Loop begins the agent loop returns only when ctx is cancelled.
60 Loop(ctx context.Context)
61
Sean McCulloughedc88dc2025-04-30 02:55:01 +000062 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070063
64 CancelToolUse(toolUseID string, cause error) error
65
66 // Returns a subset of the agent's message history.
67 Messages(start int, end int) []AgentMessage
68
69 // Returns the current number of messages in the history
70 MessageCount() int
71
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070072 TotalUsage() conversation.CumulativeUsage
73 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070074
Earl Lee2e463fb2025-04-17 11:22:22 -070075 WorkingDir() string
76
77 // Diff returns a unified diff of changes made since the agent was instantiated.
78 // If commit is non-nil, it shows the diff for just that specific commit.
79 Diff(commit *string) (string, error)
80
81 // InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
82 InitialCommit() string
83
84 // Title returns the current title of the conversation.
85 Title() string
86
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000087 // BranchName returns the git branch name for the conversation.
88 BranchName() string
89
Earl Lee2e463fb2025-04-17 11:22:22 -070090 // OS returns the operating system of the client.
91 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +000092
Philip Zeyligerc72fff52025-04-29 20:17:54 +000093 // SessionID returns the unique session identifier.
94 SessionID() string
95
Philip Zeyliger99a9a022025-04-27 15:15:25 +000096 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
97 OutstandingLLMCallCount() int
98
99 // OutstandingToolCalls returns the names of outstanding tool calls.
100 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000101 OutsideOS() string
102 OutsideHostname() string
103 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000104 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000105 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
106 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700107
108 // RestartConversation resets the conversation history
109 RestartConversation(ctx context.Context, rev string, initialPrompt string) error
110 // SuggestReprompt suggests a re-prompt based on the current conversation.
111 SuggestReprompt(ctx context.Context) (string, error)
112 // IsInContainer returns true if the agent is running in a container
113 IsInContainer() bool
114 // FirstMessageIndex returns the index of the first message in the current conversation
115 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700116
117 CurrentStateName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700118}
119
120type CodingAgentMessageType string
121
122const (
123 UserMessageType CodingAgentMessageType = "user"
124 AgentMessageType CodingAgentMessageType = "agent"
125 ErrorMessageType CodingAgentMessageType = "error"
126 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
127 ToolUseMessageType CodingAgentMessageType = "tool"
128 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
129 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
130
131 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
132)
133
134type AgentMessage struct {
135 Type CodingAgentMessageType `json:"type"`
136 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
137 EndOfTurn bool `json:"end_of_turn"`
138
139 Content string `json:"content"`
140 ToolName string `json:"tool_name,omitempty"`
141 ToolInput string `json:"input,omitempty"`
142 ToolResult string `json:"tool_result,omitempty"`
143 ToolError bool `json:"tool_error,omitempty"`
144 ToolCallId string `json:"tool_call_id,omitempty"`
145
146 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
147 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
148
Sean McCulloughd9f13372025-04-21 15:08:49 -0700149 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
150 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
151
Earl Lee2e463fb2025-04-17 11:22:22 -0700152 // Commits is a list of git commits for a commit message
153 Commits []*GitCommit `json:"commits,omitempty"`
154
155 Timestamp time.Time `json:"timestamp"`
156 ConversationID string `json:"conversation_id"`
157 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700158 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700159
160 // Message timing information
161 StartTime *time.Time `json:"start_time,omitempty"`
162 EndTime *time.Time `json:"end_time,omitempty"`
163 Elapsed *time.Duration `json:"elapsed,omitempty"`
164
165 // Turn duration - the time taken for a complete agent turn
166 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
167
168 Idx int `json:"idx"`
169}
170
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700171// SetConvo sets m.ConversationID and m.ParentConversationID based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700172func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700173 if convo == nil {
174 m.ConversationID = ""
175 m.ParentConversationID = nil
176 return
177 }
178 m.ConversationID = convo.ID
179 if convo.Parent != nil {
180 m.ParentConversationID = &convo.Parent.ID
181 }
182}
183
Earl Lee2e463fb2025-04-17 11:22:22 -0700184// GitCommit represents a single git commit for a commit message
185type GitCommit struct {
186 Hash string `json:"hash"` // Full commit hash
187 Subject string `json:"subject"` // Commit subject line
188 Body string `json:"body"` // Full commit message body
189 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
190}
191
192// ToolCall represents a single tool call within an agent message
193type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700194 Name string `json:"name"`
195 Input string `json:"input"`
196 ToolCallId string `json:"tool_call_id"`
197 ResultMessage *AgentMessage `json:"result_message,omitempty"`
198 Args string `json:"args,omitempty"`
199 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700200}
201
202func (a *AgentMessage) Attr() slog.Attr {
203 var attrs []any = []any{
204 slog.String("type", string(a.Type)),
205 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700206 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700207 if a.EndOfTurn {
208 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
209 }
210 if a.Content != "" {
211 attrs = append(attrs, slog.String("content", a.Content))
212 }
213 if a.ToolName != "" {
214 attrs = append(attrs, slog.String("tool_name", a.ToolName))
215 }
216 if a.ToolInput != "" {
217 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
218 }
219 if a.Elapsed != nil {
220 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
221 }
222 if a.TurnDuration != nil {
223 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
224 }
225 if a.ToolResult != "" {
226 attrs = append(attrs, slog.String("tool_result", a.ToolResult))
227 }
228 if a.ToolError {
229 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
230 }
231 if len(a.ToolCalls) > 0 {
232 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
233 for i, tc := range a.ToolCalls {
234 toolCallAttrs = append(toolCallAttrs, slog.Group(
235 fmt.Sprintf("tool_call_%d", i),
236 slog.String("name", tc.Name),
237 slog.String("input", tc.Input),
238 ))
239 }
240 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
241 }
242 if a.ConversationID != "" {
243 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
244 }
245 if a.ParentConversationID != nil {
246 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
247 }
248 if a.Usage != nil && !a.Usage.IsZero() {
249 attrs = append(attrs, a.Usage.Attr())
250 }
251 // TODO: timestamp, convo ids, idx?
252 return slog.Group("agent_message", attrs...)
253}
254
255func errorMessage(err error) AgentMessage {
256 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
257 if os.Getenv(("DEBUG")) == "1" {
258 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
259 }
260
261 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
262}
263
264func budgetMessage(err error) AgentMessage {
265 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
266}
267
268// ConvoInterface defines the interface for conversation interactions
269type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700270 CumulativeUsage() conversation.CumulativeUsage
271 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700272 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700273 SendMessage(message llm.Message) (*llm.Response, error)
274 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700275 GetID() string
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700276 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error)
277 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700278 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700279 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700280}
281
282type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700283 convo ConvoInterface
284 config AgentConfig // config for this agent
285 workingDir string
286 repoRoot string // workingDir may be a subdir of repoRoot
287 url string
288 firstMessageIndex int // index of the first message in the current conversation
289 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
290 initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
291 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000292 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700293 ready chan struct{} // closed when the agent is initialized (only when under docker)
294 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700295 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700296 title string
297 branchName string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000298 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700299 // State machine to track agent state
300 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000301 // Outside information
302 outsideHostname string
303 outsideOS string
304 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000305 // URL of the git remote 'origin' if it exists
306 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700307
308 // Time when the current turn started (reset at the beginning of InnerLoop)
309 startOfTurn time.Time
310
311 // Inbox - for messages from the user to the agent.
312 // sent on by UserMessage
313 // . e.g. when user types into the chat textarea
314 // read from by GatherMessages
315 inbox chan string
316
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000317 // protects cancelTurn
318 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700319 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000320 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700321
322 // protects following
323 mu sync.Mutex
324
325 // Stores all messages for this agent
326 history []AgentMessage
327
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700328 // Iterators add themselves here when they're ready to be notified of new messages.
329 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700330
331 // Track git commits we've already seen (by hash)
332 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000333
334 // Track outstanding LLM call IDs
335 outstandingLLMCalls map[string]struct{}
336
337 // Track outstanding tool calls by ID with their names
338 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700339}
340
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700341// NewIterator implements CodingAgent.
342func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
343 a.mu.Lock()
344 defer a.mu.Unlock()
345
346 return &MessageIteratorImpl{
347 agent: a,
348 ctx: ctx,
349 nextMessageIdx: nextMessageIdx,
350 ch: make(chan *AgentMessage, 100),
351 }
352}
353
354type MessageIteratorImpl struct {
355 agent *Agent
356 ctx context.Context
357 nextMessageIdx int
358 ch chan *AgentMessage
359 subscribed bool
360}
361
362func (m *MessageIteratorImpl) Close() {
363 m.agent.mu.Lock()
364 defer m.agent.mu.Unlock()
365 // Delete ourselves from the subscribers list
366 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
367 return x == m.ch
368 })
369 close(m.ch)
370}
371
372func (m *MessageIteratorImpl) Next() *AgentMessage {
373 // We avoid subscription at creation to let ourselves catch up to "current state"
374 // before subscribing.
375 if !m.subscribed {
376 m.agent.mu.Lock()
377 if m.nextMessageIdx < len(m.agent.history) {
378 msg := &m.agent.history[m.nextMessageIdx]
379 m.nextMessageIdx++
380 m.agent.mu.Unlock()
381 return msg
382 }
383 // The next message doesn't exist yet, so let's subscribe
384 m.agent.subscribers = append(m.agent.subscribers, m.ch)
385 m.subscribed = true
386 m.agent.mu.Unlock()
387 }
388
389 for {
390 select {
391 case <-m.ctx.Done():
392 m.agent.mu.Lock()
393 // Delete ourselves from the subscribers list
394 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
395 return x == m.ch
396 })
397 m.subscribed = false
398 m.agent.mu.Unlock()
399 return nil
400 case msg, ok := <-m.ch:
401 if !ok {
402 // Close may have been called
403 return nil
404 }
405 if msg.Idx == m.nextMessageIdx {
406 m.nextMessageIdx++
407 return msg
408 }
409 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
410 panic("out of order message")
411 }
412 }
413}
414
Sean McCulloughd9d45812025-04-30 16:53:41 -0700415// Assert that Agent satisfies the CodingAgent interface.
416var _ CodingAgent = &Agent{}
417
418// StateName implements CodingAgent.
419func (a *Agent) CurrentStateName() string {
420 if a.stateMachine == nil {
421 return ""
422 }
423 return a.stateMachine.currentState.String()
424}
425
Earl Lee2e463fb2025-04-17 11:22:22 -0700426func (a *Agent) URL() string { return a.url }
427
428// Title returns the current title of the conversation.
429// If no title has been set, returns an empty string.
430func (a *Agent) Title() string {
431 a.mu.Lock()
432 defer a.mu.Unlock()
433 return a.title
434}
435
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000436// BranchName returns the git branch name for the conversation.
437func (a *Agent) BranchName() string {
438 a.mu.Lock()
439 defer a.mu.Unlock()
440 return a.branchName
441}
442
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000443// OutstandingLLMCallCount returns the number of outstanding LLM calls.
444func (a *Agent) OutstandingLLMCallCount() int {
445 a.mu.Lock()
446 defer a.mu.Unlock()
447 return len(a.outstandingLLMCalls)
448}
449
450// OutstandingToolCalls returns the names of outstanding tool calls.
451func (a *Agent) OutstandingToolCalls() []string {
452 a.mu.Lock()
453 defer a.mu.Unlock()
454
455 tools := make([]string, 0, len(a.outstandingToolCalls))
456 for _, toolName := range a.outstandingToolCalls {
457 tools = append(tools, toolName)
458 }
459 return tools
460}
461
Earl Lee2e463fb2025-04-17 11:22:22 -0700462// OS returns the operating system of the client.
463func (a *Agent) OS() string {
464 return a.config.ClientGOOS
465}
466
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000467func (a *Agent) SessionID() string {
468 return a.config.SessionID
469}
470
Philip Zeyliger18532b22025-04-23 21:11:46 +0000471// OutsideOS returns the operating system of the outside system.
472func (a *Agent) OutsideOS() string {
473 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000474}
475
Philip Zeyliger18532b22025-04-23 21:11:46 +0000476// OutsideHostname returns the hostname of the outside system.
477func (a *Agent) OutsideHostname() string {
478 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000479}
480
Philip Zeyliger18532b22025-04-23 21:11:46 +0000481// OutsideWorkingDir returns the working directory on the outside system.
482func (a *Agent) OutsideWorkingDir() string {
483 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000484}
485
486// GitOrigin returns the URL of the git remote 'origin' if it exists.
487func (a *Agent) GitOrigin() string {
488 return a.gitOrigin
489}
490
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000491func (a *Agent) OpenBrowser(url string) {
492 if !a.IsInContainer() {
493 browser.Open(url)
494 return
495 }
496 // We're in Docker, need to send a request to the Git server
497 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700498 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000499 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700500 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000501 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700502 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000503 return
504 }
505 defer resp.Body.Close()
506 if resp.StatusCode == http.StatusOK {
507 return
508 }
509 body, _ := io.ReadAll(resp.Body)
510 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
511}
512
Sean McCullough96b60dd2025-04-30 09:49:10 -0700513// CurrentState returns the current state of the agent's state machine.
514func (a *Agent) CurrentState() State {
515 return a.stateMachine.CurrentState()
516}
517
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700518func (a *Agent) IsInContainer() bool {
519 return a.config.InDocker
520}
521
522func (a *Agent) FirstMessageIndex() int {
523 a.mu.Lock()
524 defer a.mu.Unlock()
525 return a.firstMessageIndex
526}
527
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000528// SetTitle sets the title of the conversation.
529func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700530 a.mu.Lock()
531 defer a.mu.Unlock()
532 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000533}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700534
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000535// SetBranch sets the branch name of the conversation.
536func (a *Agent) SetBranch(branchName string) {
537 a.mu.Lock()
538 defer a.mu.Unlock()
539 a.branchName = branchName
Earl Lee2e463fb2025-04-17 11:22:22 -0700540}
541
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000542// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700543func (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 +0000544 // Track the tool call
545 a.mu.Lock()
546 a.outstandingToolCalls[id] = toolName
547 a.mu.Unlock()
548}
549
Earl Lee2e463fb2025-04-17 11:22:22 -0700550// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700551func (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 +0000552 // Remove the tool call from outstanding calls
553 a.mu.Lock()
554 delete(a.outstandingToolCalls, toolID)
555 a.mu.Unlock()
556
Earl Lee2e463fb2025-04-17 11:22:22 -0700557 m := AgentMessage{
558 Type: ToolUseMessageType,
559 Content: content.Text,
560 ToolResult: content.ToolResult,
561 ToolError: content.ToolError,
562 ToolName: toolName,
563 ToolInput: string(toolInput),
564 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700565 StartTime: content.ToolUseStartTime,
566 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700567 }
568
569 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700570 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
571 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700572 m.Elapsed = &elapsed
573 }
574
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700575 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700576 a.pushToOutbox(ctx, m)
577}
578
579// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700580func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000581 a.mu.Lock()
582 defer a.mu.Unlock()
583 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700584 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
585}
586
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700587// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700588// that need to be displayed (as well as tool calls that we send along when
589// they're done). (It would be reasonable to also mention tool calls when they're
590// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700591func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000592 // Remove the LLM call from outstanding calls
593 a.mu.Lock()
594 delete(a.outstandingLLMCalls, id)
595 a.mu.Unlock()
596
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700597 if resp == nil {
598 // LLM API call failed
599 m := AgentMessage{
600 Type: ErrorMessageType,
601 Content: "API call failed, type 'continue' to try again",
602 }
603 m.SetConvo(convo)
604 a.pushToOutbox(ctx, m)
605 return
606 }
607
Earl Lee2e463fb2025-04-17 11:22:22 -0700608 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700609 if convo.Parent == nil { // subconvos never end the turn
610 switch resp.StopReason {
611 case llm.StopReasonToolUse:
612 // Check whether any of the tool calls are for tools that should end the turn
613 ToolSearch:
614 for _, part := range resp.Content {
615 if part.Type != llm.ContentTypeToolUse {
616 continue
617 }
Sean McCullough021557a2025-05-05 23:20:53 +0000618 // Find the tool by name
619 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700620 if tool.Name == part.ToolName {
621 endOfTurn = tool.EndsTurn
622 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000623 }
624 }
Sean McCullough021557a2025-05-05 23:20:53 +0000625 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700626 default:
627 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000628 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700629 }
630 m := AgentMessage{
631 Type: AgentMessageType,
632 Content: collectTextContent(resp),
633 EndOfTurn: endOfTurn,
634 Usage: &resp.Usage,
635 StartTime: resp.StartTime,
636 EndTime: resp.EndTime,
637 }
638
639 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700640 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700641 var toolCalls []ToolCall
642 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700643 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700644 toolCalls = append(toolCalls, ToolCall{
645 Name: part.ToolName,
646 Input: string(part.ToolInput),
647 ToolCallId: part.ID,
648 })
649 }
650 }
651 m.ToolCalls = toolCalls
652 }
653
654 // Calculate the elapsed time if both start and end times are set
655 if resp.StartTime != nil && resp.EndTime != nil {
656 elapsed := resp.EndTime.Sub(*resp.StartTime)
657 m.Elapsed = &elapsed
658 }
659
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700660 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700661 a.pushToOutbox(ctx, m)
662}
663
664// WorkingDir implements CodingAgent.
665func (a *Agent) WorkingDir() string {
666 return a.workingDir
667}
668
669// MessageCount implements CodingAgent.
670func (a *Agent) MessageCount() int {
671 a.mu.Lock()
672 defer a.mu.Unlock()
673 return len(a.history)
674}
675
676// Messages implements CodingAgent.
677func (a *Agent) Messages(start int, end int) []AgentMessage {
678 a.mu.Lock()
679 defer a.mu.Unlock()
680 return slices.Clone(a.history[start:end])
681}
682
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700683func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700684 return a.originalBudget
685}
686
687// AgentConfig contains configuration for creating a new Agent.
688type AgentConfig struct {
689 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700690 Service llm.Service
691 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700692 GitUsername string
693 GitEmail string
694 SessionID string
695 ClientGOOS string
696 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700697 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700698 UseAnthropicEdit bool
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000699 OneShot bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000700 // Outside information
701 OutsideHostname string
702 OutsideOS string
703 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700704}
705
706// NewAgent creates a new Agent.
707// It is not usable until Init() is called.
708func NewAgent(config AgentConfig) *Agent {
709 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000710 config: config,
711 ready: make(chan struct{}),
712 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700713 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000714 startedAt: time.Now(),
715 originalBudget: config.Budget,
716 seenCommits: make(map[string]bool),
717 outsideHostname: config.OutsideHostname,
718 outsideOS: config.OutsideOS,
719 outsideWorkingDir: config.OutsideWorkingDir,
720 outstandingLLMCalls: make(map[string]struct{}),
721 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700722 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700723 }
724 return agent
725}
726
727type AgentInit struct {
728 WorkingDir string
729 NoGit bool // only for testing
730
731 InDocker bool
732 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000733 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700734 GitRemoteAddr string
735 HostAddr string
736}
737
738func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700739 if a.convo != nil {
740 return fmt.Errorf("Agent.Init: already initialized")
741 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700742 ctx := a.config.Context
743 if ini.InDocker {
744 cmd := exec.CommandContext(ctx, "git", "stash")
745 cmd.Dir = ini.WorkingDir
746 if out, err := cmd.CombinedOutput(); err != nil {
747 return fmt.Errorf("git stash: %s: %v", out, err)
748 }
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700749 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
750 cmd.Dir = ini.WorkingDir
751 if out, err := cmd.CombinedOutput(); err != nil {
752 return fmt.Errorf("git remote add: %s: %v", out, err)
753 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000754 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700755 cmd.Dir = ini.WorkingDir
756 if out, err := cmd.CombinedOutput(); err != nil {
757 return fmt.Errorf("git fetch: %s: %w", out, err)
758 }
759 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
760 cmd.Dir = ini.WorkingDir
761 if out, err := cmd.CombinedOutput(); err != nil {
762 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
763 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700764 a.lastHEAD = ini.Commit
765 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000766 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700767 a.initialCommit = ini.Commit
768 if ini.HostAddr != "" {
769 a.url = "http://" + ini.HostAddr
770 }
771 }
772 a.workingDir = ini.WorkingDir
773
774 if !ini.NoGit {
775 repoRoot, err := repoRoot(ctx, a.workingDir)
776 if err != nil {
777 return fmt.Errorf("repoRoot: %w", err)
778 }
779 a.repoRoot = repoRoot
780
781 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
782 if err != nil {
783 return fmt.Errorf("resolveRef: %w", err)
784 }
785 a.initialCommit = commitHash
786
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000787 llmCodeReview := codereview.NoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700788 if experiment.Enabled("llm_review") {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000789 llmCodeReview = codereview.DoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700790 }
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000791 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit, llmCodeReview)
Earl Lee2e463fb2025-04-17 11:22:22 -0700792 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000793 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700794 }
795 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000796
797 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700798 }
799 a.lastHEAD = a.initialCommit
800 a.convo = a.initConvo()
801 close(a.ready)
802 return nil
803}
804
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700805//go:embed agent_system_prompt.txt
806var agentSystemPrompt string
807
Earl Lee2e463fb2025-04-17 11:22:22 -0700808// initConvo initializes the conversation.
809// It must not be called until all agent fields are initialized,
810// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700811func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700812 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700813 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700814 convo.PromptCaching = true
815 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000816 convo.SystemPrompt = a.renderSystemPrompt()
Earl Lee2e463fb2025-04-17 11:22:22 -0700817
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000818 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
819 bashPermissionCheck := func(command string) error {
820 // Check if branch name is set
821 a.mu.Lock()
822 branchSet := a.branchName != ""
823 a.mu.Unlock()
824
825 // If branch is set, all commands are allowed
826 if branchSet {
827 return nil
828 }
829
830 // If branch is not set, check if this is a git commit command
831 willCommit, err := bashkit.WillRunGitCommit(command)
832 if err != nil {
833 // If there's an error checking, we should allow the command to proceed
834 return nil
835 }
836
837 // If it's a git commit and branch is not set, return an error
838 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000839 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000840 }
841
842 return nil
843 }
844
845 // Create a custom bash tool with the permission check
846 bashTool := claudetool.NewBashTool(bashPermissionCheck)
847
Earl Lee2e463fb2025-04-17 11:22:22 -0700848 // Register all tools with the conversation
849 // When adding, removing, or modifying tools here, double-check that the termui tool display
850 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000851
852 var browserTools []*llm.Tool
853 // Add browser tools if enabled
854 // if experiment.Enabled("browser") {
855 if true {
856 bTools, browserCleanup := browse.RegisterBrowserTools(a.config.Context)
857 // Add cleanup function to context cancel
858 go func() {
859 <-a.config.Context.Done()
860 browserCleanup()
861 }()
862 browserTools = bTools
863 }
864
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700865 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000866 bashTool, claudetool.Keyword,
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000867 claudetool.Think, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000868 a.codereview.Tool(),
869 }
870
871 // One-shot mode is non-interactive, multiple choice requires human response
872 if !a.config.OneShot {
873 convo.Tools = append(convo.Tools, a.multipleChoiceTool())
Earl Lee2e463fb2025-04-17 11:22:22 -0700874 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000875
876 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -0700877 if a.config.UseAnthropicEdit {
878 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
879 } else {
880 convo.Tools = append(convo.Tools, claudetool.Patch)
881 }
882 convo.Listener = a
883 return convo
884}
885
Sean McCullough485afc62025-04-28 14:28:39 -0700886func (a *Agent) multipleChoiceTool() *llm.Tool {
887 ret := &llm.Tool{
888 Name: "multiplechoice",
889 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 +0000890 EndsTurn: true,
Sean McCullough485afc62025-04-28 14:28:39 -0700891 InputSchema: json.RawMessage(`{
892 "type": "object",
893 "description": "The question and a list of answers you would expect the user to choose from.",
894 "properties": {
895 "question": {
896 "type": "string",
897 "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?'"
898 },
899 "responseOptions": {
900 "type": "array",
901 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
902 "items": {
903 "type": "object",
904 "properties": {
905 "caption": {
906 "type": "string",
907 "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'"
908 },
909 "responseText": {
910 "type": "string",
911 "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'"
912 }
913 },
914 "required": ["caption", "responseText"]
915 }
916 }
917 },
918 "required": ["question", "responseOptions"]
919}`),
920 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
921 // The Run logic for "multiplchoice" tool is a no-op on the server.
922 // The UI will present a list of options for the user to select from,
923 // and that's it as far as "executing" the tool_use goes.
924 // When the user *does* select one of the presented options, that
925 // responseText gets sent as a chat message on behalf of the user.
926 return "end your turn and wait for the user to respond", nil
927 },
928 }
929 return ret
930}
931
932type MultipleChoiceOption struct {
933 Caption string `json:"caption"`
934 ResponseText string `json:"responseText"`
935}
936
937type MultipleChoiceParams struct {
938 Question string `json:"question"`
939 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
940}
941
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000942// branchExists reports whether branchName exists, either locally or in well-known remotes.
943func branchExists(dir, branchName string) bool {
944 refs := []string{
945 "refs/heads/",
946 "refs/remotes/origin/",
947 "refs/remotes/sketch-host/",
948 }
949 for _, ref := range refs {
950 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
951 cmd.Dir = dir
952 if cmd.Run() == nil { // exit code 0 means branch exists
953 return true
954 }
955 }
956 return false
957}
958
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000959func (a *Agent) titleTool() *llm.Tool {
960 description := `Sets the conversation title.`
961 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -0700962 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000963 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -0700964 InputSchema: json.RawMessage(`{
965 "type": "object",
966 "properties": {
967 "title": {
968 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000969 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -0700970 }
971 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000972 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -0700973}`),
974 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
975 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000976 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700977 }
978 if err := json.Unmarshal(input, &params); err != nil {
979 return "", err
980 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000981
982 // We don't allow changing the title once set to be consistent with the previous behavior
983 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700984 t := a.Title()
985 if t != "" {
986 return "", fmt.Errorf("title already set to: %s", t)
987 }
988
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700989 if params.Title == "" {
990 return "", fmt.Errorf("title parameter cannot be empty")
991 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000992
993 a.SetTitle(params.Title)
994 response := fmt.Sprintf("Title set to %q", params.Title)
995 return response, nil
996 },
997 }
998 return titleTool
999}
1000
1001func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001002 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 +00001003 preCommit := &llm.Tool{
1004 Name: "precommit",
1005 Description: description,
1006 InputSchema: json.RawMessage(`{
1007 "type": "object",
1008 "properties": {
1009 "branch_name": {
1010 "type": "string",
1011 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1012 }
1013 },
1014 "required": ["branch_name"]
1015}`),
1016 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
1017 var params struct {
1018 BranchName string `json:"branch_name"`
1019 }
1020 if err := json.Unmarshal(input, &params); err != nil {
1021 return "", err
1022 }
1023
1024 b := a.BranchName()
1025 if b != "" {
1026 return "", fmt.Errorf("branch already set to: %s", b)
1027 }
1028
1029 if params.BranchName == "" {
1030 return "", fmt.Errorf("branch_name parameter cannot be empty")
1031 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001032 if params.BranchName != cleanBranchName(params.BranchName) {
1033 return "", fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
1034 }
1035 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001036 if branchExists(a.workingDir, branchName) {
1037 return "", fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
1038 }
1039
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001040 a.SetBranch(branchName)
1041 response := fmt.Sprintf("Branch name set to %q", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001042
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001043 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1044 if err != nil {
1045 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1046 }
1047 if len(styleHint) > 0 {
1048 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001049 }
1050
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001051 return response, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001052 },
1053 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001054 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001055}
1056
1057func (a *Agent) Ready() <-chan struct{} {
1058 return a.ready
1059}
1060
1061func (a *Agent) UserMessage(ctx context.Context, msg string) {
1062 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1063 a.inbox <- msg
1064}
1065
Sean McCullough485afc62025-04-28 14:28:39 -07001066func (a *Agent) ToolResultMessage(ctx context.Context, toolCallID, msg string) {
1067 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg, ToolCallId: toolCallID})
1068 a.inbox <- msg
1069}
1070
Earl Lee2e463fb2025-04-17 11:22:22 -07001071func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1072 return a.convo.CancelToolUse(toolUseID, cause)
1073}
1074
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001075func (a *Agent) CancelTurn(cause error) {
1076 a.cancelTurnMu.Lock()
1077 defer a.cancelTurnMu.Unlock()
1078 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001079 // Force state transition to cancelled state
1080 ctx := a.config.Context
1081 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001082 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001083 }
1084}
1085
1086func (a *Agent) Loop(ctxOuter context.Context) {
1087 for {
1088 select {
1089 case <-ctxOuter.Done():
1090 return
1091 default:
1092 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001093 a.cancelTurnMu.Lock()
1094 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001095 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001096 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001097 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001098 a.cancelTurn = cancel
1099 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001100 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1101 if err != nil {
1102 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1103 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001104 cancel(nil)
1105 }
1106 }
1107}
1108
1109func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1110 if m.Timestamp.IsZero() {
1111 m.Timestamp = time.Now()
1112 }
1113
1114 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1115 if m.EndOfTurn && m.Type == AgentMessageType {
1116 turnDuration := time.Since(a.startOfTurn)
1117 m.TurnDuration = &turnDuration
1118 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1119 }
1120
Earl Lee2e463fb2025-04-17 11:22:22 -07001121 a.mu.Lock()
1122 defer a.mu.Unlock()
1123 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001124 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001125 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001126
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001127 // Notify all subscribers
1128 for _, ch := range a.subscribers {
1129 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001130 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001131}
1132
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001133func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1134 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001135 if block {
1136 select {
1137 case <-ctx.Done():
1138 return m, ctx.Err()
1139 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001140 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001141 }
1142 }
1143 for {
1144 select {
1145 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001146 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001147 default:
1148 return m, nil
1149 }
1150 }
1151}
1152
Sean McCullough885a16a2025-04-30 02:49:25 +00001153// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001154func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001155 // Reset the start of turn time
1156 a.startOfTurn = time.Now()
1157
Sean McCullough96b60dd2025-04-30 09:49:10 -07001158 // Transition to waiting for user input state
1159 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1160
Sean McCullough885a16a2025-04-30 02:49:25 +00001161 // Process initial user message
1162 initialResp, err := a.processUserMessage(ctx)
1163 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001164 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001165 return err
1166 }
1167
1168 // Handle edge case where both initialResp and err are nil
1169 if initialResp == nil {
1170 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001171 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1172
Sean McCullough9f4b8082025-04-30 17:34:07 +00001173 a.pushToOutbox(ctx, errorMessage(err))
1174 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001175 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001176
Earl Lee2e463fb2025-04-17 11:22:22 -07001177 // We do this as we go, but let's also do it at the end of the turn
1178 defer func() {
1179 if _, err := a.handleGitCommits(ctx); err != nil {
1180 // Just log the error, don't stop execution
1181 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1182 }
1183 }()
1184
Sean McCullougha1e0e492025-05-01 10:51:08 -07001185 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001186 resp := initialResp
1187 for {
1188 // Check if we are over budget
1189 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001190 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001191 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001192 }
1193
1194 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001195 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001196 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001197 break
1198 }
1199
Sean McCullough96b60dd2025-04-30 09:49:10 -07001200 // Transition to tool use requested state
1201 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1202
Sean McCullough885a16a2025-04-30 02:49:25 +00001203 // Handle tool execution
1204 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1205 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001206 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001207 }
1208
Sean McCullougha1e0e492025-05-01 10:51:08 -07001209 if toolResp == nil {
1210 return fmt.Errorf("cannot continue conversation with a nil tool response")
1211 }
1212
Sean McCullough885a16a2025-04-30 02:49:25 +00001213 // Set the response for the next iteration
1214 resp = toolResp
1215 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001216
1217 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001218}
1219
1220// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001221func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001222 // Wait for at least one message from the user
1223 msgs, err := a.GatherMessages(ctx, true)
1224 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001225 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001226 return nil, err
1227 }
1228
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001229 userMessage := llm.Message{
1230 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001231 Content: msgs,
1232 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001233
Sean McCullough96b60dd2025-04-30 09:49:10 -07001234 // Transition to sending to LLM state
1235 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1236
Sean McCullough885a16a2025-04-30 02:49:25 +00001237 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001238 resp, err := a.convo.SendMessage(userMessage)
1239 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001240 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001241 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001242 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001243 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001244
Sean McCullough96b60dd2025-04-30 09:49:10 -07001245 // Transition to processing LLM response state
1246 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1247
Sean McCullough885a16a2025-04-30 02:49:25 +00001248 return resp, nil
1249}
1250
1251// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001252func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1253 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001254 cancelled := false
1255
Sean McCullough96b60dd2025-04-30 09:49:10 -07001256 // Transition to checking for cancellation state
1257 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1258
Sean McCullough885a16a2025-04-30 02:49:25 +00001259 // Check if the operation was cancelled by the user
1260 select {
1261 case <-ctx.Done():
1262 // Don't actually run any of the tools, but rather build a response
1263 // for each tool_use message letting the LLM know that user canceled it.
1264 var err error
1265 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001266 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001267 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001268 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001269 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001270 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001271 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001272 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001273 // Transition to running tool state
1274 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1275
Sean McCullough885a16a2025-04-30 02:49:25 +00001276 // Add working directory to context for tool execution
1277 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1278
1279 // Execute the tools
1280 var err error
1281 results, err = a.convo.ToolResultContents(ctx, resp)
1282 if ctx.Err() != nil { // e.g. the user canceled the operation
1283 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001284 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001285 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001286 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001287 a.pushToOutbox(ctx, errorMessage(err))
1288 }
1289 }
1290
1291 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001292 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001293 autoqualityMessages := a.processGitChanges(ctx)
1294
1295 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001296 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001297 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001298 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001299 return false, nil
1300 }
1301
1302 // Continue the conversation with tool results and any user messages
1303 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1304}
1305
1306// processGitChanges checks for new git commits and runs autoformatters if needed
1307func (a *Agent) processGitChanges(ctx context.Context) []string {
1308 // Check for git commits after tool execution
1309 newCommits, err := a.handleGitCommits(ctx)
1310 if err != nil {
1311 // Just log the error, don't stop execution
1312 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1313 return nil
1314 }
1315
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001316 // Run mechanical checks if there was exactly one new commit.
1317 if len(newCommits) != 1 {
1318 return nil
1319 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001320 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001321 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1322 msg := a.codereview.RunMechanicalChecks(ctx)
1323 if msg != "" {
1324 a.pushToOutbox(ctx, AgentMessage{
1325 Type: AutoMessageType,
1326 Content: msg,
1327 Timestamp: time.Now(),
1328 })
1329 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001330 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001331
1332 return autoqualityMessages
1333}
1334
1335// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001336func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001337 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001338 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001339 msgs, err := a.GatherMessages(ctx, false)
1340 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001341 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001342 return false, nil
1343 }
1344
1345 // Inject any auto-generated messages from quality checks
1346 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001347 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001348 }
1349
1350 // Handle cancellation by appending a message about it
1351 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001352 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001353 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001354 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001355 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1356 } else if err := a.convo.OverBudget(); err != nil {
1357 // Handle budget issues by appending a message about it
1358 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 -07001359 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001360 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1361 }
1362
1363 // Combine tool results with user messages
1364 results = append(results, msgs...)
1365
1366 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001367 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001368 resp, err := a.convo.SendMessage(llm.Message{
1369 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001370 Content: results,
1371 })
1372 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001373 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001374 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1375 return true, nil // Return true to continue the conversation, but with no response
1376 }
1377
Sean McCullough96b60dd2025-04-30 09:49:10 -07001378 // Transition back to processing LLM response
1379 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1380
Sean McCullough885a16a2025-04-30 02:49:25 +00001381 if cancelled {
1382 return false, nil
1383 }
1384
1385 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001386}
1387
1388func (a *Agent) overBudget(ctx context.Context) error {
1389 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001390 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001391 m := budgetMessage(err)
1392 m.Content = m.Content + "\n\nBudget reset."
1393 a.pushToOutbox(ctx, budgetMessage(err))
1394 a.convo.ResetBudget(a.originalBudget)
1395 return err
1396 }
1397 return nil
1398}
1399
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001400func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001401 // Collect all text content
1402 var allText strings.Builder
1403 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001404 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001405 if allText.Len() > 0 {
1406 allText.WriteString("\n\n")
1407 }
1408 allText.WriteString(content.Text)
1409 }
1410 }
1411 return allText.String()
1412}
1413
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001414func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001415 a.mu.Lock()
1416 defer a.mu.Unlock()
1417 return a.convo.CumulativeUsage()
1418}
1419
Earl Lee2e463fb2025-04-17 11:22:22 -07001420// Diff returns a unified diff of changes made since the agent was instantiated.
1421func (a *Agent) Diff(commit *string) (string, error) {
1422 if a.initialCommit == "" {
1423 return "", fmt.Errorf("no initial commit reference available")
1424 }
1425
1426 // Find the repository root
1427 ctx := context.Background()
1428
1429 // If a specific commit hash is provided, show just that commit's changes
1430 if commit != nil && *commit != "" {
1431 // Validate that the commit looks like a valid git SHA
1432 if !isValidGitSHA(*commit) {
1433 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1434 }
1435
1436 // Get the diff for just this commit
1437 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1438 cmd.Dir = a.repoRoot
1439 output, err := cmd.CombinedOutput()
1440 if err != nil {
1441 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1442 }
1443 return string(output), nil
1444 }
1445
1446 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1447 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1448 cmd.Dir = a.repoRoot
1449 output, err := cmd.CombinedOutput()
1450 if err != nil {
1451 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1452 }
1453
1454 return string(output), nil
1455}
1456
1457// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1458func (a *Agent) InitialCommit() string {
1459 return a.initialCommit
1460}
1461
1462// handleGitCommits() highlights new commits to the user. When running
1463// under docker, new HEADs are pushed to a branch according to the title.
1464func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1465 if a.repoRoot == "" {
1466 return nil, nil
1467 }
1468
1469 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1470 if err != nil {
1471 return nil, err
1472 }
1473 if head == a.lastHEAD {
1474 return nil, nil // nothing to do
1475 }
1476 defer func() {
1477 a.lastHEAD = head
1478 }()
1479
1480 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1481 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1482 // to the last 100 commits.
1483 var commits []*GitCommit
1484
1485 // Get commits since the initial commit
1486 // Format: <hash>\0<subject>\0<body>\0
1487 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1488 // Limit to 100 commits to avoid overwhelming the user
1489 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1490 cmd.Dir = a.repoRoot
1491 output, err := cmd.Output()
1492 if err != nil {
1493 return nil, fmt.Errorf("failed to get git log: %w", err)
1494 }
1495
1496 // Parse git log output and filter out already seen commits
1497 parsedCommits := parseGitLog(string(output))
1498
1499 var headCommit *GitCommit
1500
1501 // Filter out commits we've already seen
1502 for _, commit := range parsedCommits {
1503 if commit.Hash == head {
1504 headCommit = &commit
1505 }
1506
1507 // Skip if we've seen this commit before. If our head has changed, always include that.
1508 if a.seenCommits[commit.Hash] && commit.Hash != head {
1509 continue
1510 }
1511
1512 // Mark this commit as seen
1513 a.seenCommits[commit.Hash] = true
1514
1515 // Add to our list of new commits
1516 commits = append(commits, &commit)
1517 }
1518
1519 if a.gitRemoteAddr != "" {
1520 if headCommit == nil {
1521 // I think this can only happen if we have a bug or if there's a race.
1522 headCommit = &GitCommit{}
1523 headCommit.Hash = head
1524 headCommit.Subject = "unknown"
1525 commits = append(commits, headCommit)
1526 }
1527
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001528 branch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
Earl Lee2e463fb2025-04-17 11:22:22 -07001529
1530 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1531 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1532 // then use push with lease to replace.
1533 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1534 cmd.Dir = a.workingDir
1535 if out, err := cmd.CombinedOutput(); err != nil {
1536 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1537 } else {
1538 headCommit.PushedBranch = branch
1539 }
1540 }
1541
1542 // If we found new commits, create a message
1543 if len(commits) > 0 {
1544 msg := AgentMessage{
1545 Type: CommitMessageType,
1546 Timestamp: time.Now(),
1547 Commits: commits,
1548 }
1549 a.pushToOutbox(ctx, msg)
1550 }
1551 return commits, nil
1552}
1553
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001554func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001555 return strings.Map(func(r rune) rune {
1556 // lowercase
1557 if r >= 'A' && r <= 'Z' {
1558 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001559 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001560 // replace spaces with dashes
1561 if r == ' ' {
1562 return '-'
1563 }
1564 // allow alphanumerics and dashes
1565 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1566 return r
1567 }
1568 return -1
1569 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001570}
1571
1572// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1573// and returns an array of GitCommit structs.
1574func parseGitLog(output string) []GitCommit {
1575 var commits []GitCommit
1576
1577 // No output means no commits
1578 if len(output) == 0 {
1579 return commits
1580 }
1581
1582 // Split by NULL byte
1583 parts := strings.Split(output, "\x00")
1584
1585 // Process in triplets (hash, subject, body)
1586 for i := 0; i < len(parts); i++ {
1587 // Skip empty parts
1588 if parts[i] == "" {
1589 continue
1590 }
1591
1592 // This should be a hash
1593 hash := strings.TrimSpace(parts[i])
1594
1595 // Make sure we have at least a subject part available
1596 if i+1 >= len(parts) {
1597 break // No more parts available
1598 }
1599
1600 // Get the subject
1601 subject := strings.TrimSpace(parts[i+1])
1602
1603 // Get the body if available
1604 body := ""
1605 if i+2 < len(parts) {
1606 body = strings.TrimSpace(parts[i+2])
1607 }
1608
1609 // Skip to the next triplet
1610 i += 2
1611
1612 commits = append(commits, GitCommit{
1613 Hash: hash,
1614 Subject: subject,
1615 Body: body,
1616 })
1617 }
1618
1619 return commits
1620}
1621
1622func repoRoot(ctx context.Context, dir string) (string, error) {
1623 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1624 stderr := new(strings.Builder)
1625 cmd.Stderr = stderr
1626 cmd.Dir = dir
1627 out, err := cmd.Output()
1628 if err != nil {
1629 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1630 }
1631 return strings.TrimSpace(string(out)), nil
1632}
1633
1634func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1635 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1636 stderr := new(strings.Builder)
1637 cmd.Stderr = stderr
1638 cmd.Dir = dir
1639 out, err := cmd.Output()
1640 if err != nil {
1641 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1642 }
1643 // TODO: validate that out is valid hex
1644 return strings.TrimSpace(string(out)), nil
1645}
1646
1647// isValidGitSHA validates if a string looks like a valid git SHA hash.
1648// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1649func isValidGitSHA(sha string) bool {
1650 // Git SHA must be a hexadecimal string with at least 4 characters
1651 if len(sha) < 4 || len(sha) > 40 {
1652 return false
1653 }
1654
1655 // Check if the string only contains hexadecimal characters
1656 for _, char := range sha {
1657 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1658 return false
1659 }
1660 }
1661
1662 return true
1663}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001664
1665// getGitOrigin returns the URL of the git remote 'origin' if it exists
1666func getGitOrigin(ctx context.Context, dir string) string {
1667 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1668 cmd.Dir = dir
1669 stderr := new(strings.Builder)
1670 cmd.Stderr = stderr
1671 out, err := cmd.Output()
1672 if err != nil {
1673 return ""
1674 }
1675 return strings.TrimSpace(string(out))
1676}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001677
1678func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1679 cmd := exec.CommandContext(ctx, "git", "stash")
1680 cmd.Dir = workingDir
1681 if out, err := cmd.CombinedOutput(); err != nil {
1682 return fmt.Errorf("git stash: %s: %v", out, err)
1683 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001684 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001685 cmd.Dir = workingDir
1686 if out, err := cmd.CombinedOutput(); err != nil {
1687 return fmt.Errorf("git fetch: %s: %w", out, err)
1688 }
1689 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1690 cmd.Dir = workingDir
1691 if out, err := cmd.CombinedOutput(); err != nil {
1692 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1693 }
1694 a.lastHEAD = revision
1695 a.initialCommit = revision
1696 return nil
1697}
1698
1699func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1700 a.mu.Lock()
1701 a.title = ""
1702 a.firstMessageIndex = len(a.history)
1703 a.convo = a.initConvo()
1704 gitReset := func() error {
1705 if a.config.InDocker && rev != "" {
1706 err := a.initGitRevision(ctx, a.workingDir, rev)
1707 if err != nil {
1708 return err
1709 }
1710 } else if !a.config.InDocker && rev != "" {
1711 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1712 }
1713 return nil
1714 }
1715 err := gitReset()
1716 a.mu.Unlock()
1717 if err != nil {
1718 a.pushToOutbox(a.config.Context, errorMessage(err))
1719 }
1720
1721 a.pushToOutbox(a.config.Context, AgentMessage{
1722 Type: AgentMessageType, Content: "Conversation restarted.",
1723 })
1724 if initialPrompt != "" {
1725 a.UserMessage(ctx, initialPrompt)
1726 }
1727 return nil
1728}
1729
1730func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1731 msg := `The user has requested a suggestion for a re-prompt.
1732
1733 Given the current conversation thus far, suggest a re-prompt that would
1734 capture the instructions and feedback so far, as well as any
1735 research or other information that would be helpful in implementing
1736 the task.
1737
1738 Reply with ONLY the reprompt text.
1739 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001740 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001741 // By doing this in a subconversation, the agent doesn't call tools (because
1742 // there aren't any), and there's not a concurrency risk with on-going other
1743 // outstanding conversations.
1744 convo := a.convo.SubConvoWithHistory()
1745 resp, err := convo.SendMessage(userMessage)
1746 if err != nil {
1747 a.pushToOutbox(ctx, errorMessage(err))
1748 return "", err
1749 }
1750 textContent := collectTextContent(resp)
1751 return textContent, nil
1752}
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001753
1754// systemPromptData contains the data used to render the system prompt template
1755type systemPromptData struct {
1756 EditPrompt string
1757 ClientGOOS string
1758 ClientGOARCH string
1759 WorkingDir string
1760 RepoRoot string
1761 InitialCommit string
1762}
1763
1764// renderSystemPrompt renders the system prompt template.
1765func (a *Agent) renderSystemPrompt() string {
1766 // Determine the appropriate edit prompt based on config
1767 var editPrompt string
1768 if a.config.UseAnthropicEdit {
1769 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."
1770 } else {
1771 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1772 }
1773
1774 data := systemPromptData{
1775 EditPrompt: editPrompt,
1776 ClientGOOS: a.config.ClientGOOS,
1777 ClientGOARCH: a.config.ClientGOARCH,
1778 WorkingDir: a.workingDir,
1779 RepoRoot: a.repoRoot,
1780 InitialCommit: a.initialCommit,
1781 }
1782
1783 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1784 if err != nil {
1785 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1786 }
1787 buf := new(strings.Builder)
1788 err = tmpl.Execute(buf, data)
1789 if err != nil {
1790 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1791 }
1792 return buf.String()
1793}