blob: 5b63dee6283ef30c6d2a2f9beb6ed63d6fff16e5 [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
Philip Zeyliger18532b22025-04-23 21:11:46 +0000699 // Outside information
700 OutsideHostname string
701 OutsideOS string
702 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700703}
704
705// NewAgent creates a new Agent.
706// It is not usable until Init() is called.
707func NewAgent(config AgentConfig) *Agent {
708 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000709 config: config,
710 ready: make(chan struct{}),
711 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700712 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000713 startedAt: time.Now(),
714 originalBudget: config.Budget,
715 seenCommits: make(map[string]bool),
716 outsideHostname: config.OutsideHostname,
717 outsideOS: config.OutsideOS,
718 outsideWorkingDir: config.OutsideWorkingDir,
719 outstandingLLMCalls: make(map[string]struct{}),
720 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700721 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700722 }
723 return agent
724}
725
726type AgentInit struct {
727 WorkingDir string
728 NoGit bool // only for testing
729
730 InDocker bool
731 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000732 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700733 GitRemoteAddr string
734 HostAddr string
735}
736
737func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700738 if a.convo != nil {
739 return fmt.Errorf("Agent.Init: already initialized")
740 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700741 ctx := a.config.Context
742 if ini.InDocker {
743 cmd := exec.CommandContext(ctx, "git", "stash")
744 cmd.Dir = ini.WorkingDir
745 if out, err := cmd.CombinedOutput(); err != nil {
746 return fmt.Errorf("git stash: %s: %v", out, err)
747 }
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700748 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
749 cmd.Dir = ini.WorkingDir
750 if out, err := cmd.CombinedOutput(); err != nil {
751 return fmt.Errorf("git remote add: %s: %v", out, err)
752 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000753 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700754 cmd.Dir = ini.WorkingDir
755 if out, err := cmd.CombinedOutput(); err != nil {
756 return fmt.Errorf("git fetch: %s: %w", out, err)
757 }
758 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
759 cmd.Dir = ini.WorkingDir
760 if out, err := cmd.CombinedOutput(); err != nil {
761 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
762 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700763 a.lastHEAD = ini.Commit
764 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000765 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700766 a.initialCommit = ini.Commit
767 if ini.HostAddr != "" {
768 a.url = "http://" + ini.HostAddr
769 }
770 }
771 a.workingDir = ini.WorkingDir
772
773 if !ini.NoGit {
774 repoRoot, err := repoRoot(ctx, a.workingDir)
775 if err != nil {
776 return fmt.Errorf("repoRoot: %w", err)
777 }
778 a.repoRoot = repoRoot
779
780 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
781 if err != nil {
782 return fmt.Errorf("resolveRef: %w", err)
783 }
784 a.initialCommit = commitHash
785
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000786 llmCodeReview := codereview.NoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700787 if experiment.Enabled("llm_review") {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000788 llmCodeReview = codereview.DoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700789 }
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000790 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit, llmCodeReview)
Earl Lee2e463fb2025-04-17 11:22:22 -0700791 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000792 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700793 }
794 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000795
796 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700797 }
798 a.lastHEAD = a.initialCommit
799 a.convo = a.initConvo()
800 close(a.ready)
801 return nil
802}
803
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700804//go:embed agent_system_prompt.txt
805var agentSystemPrompt string
806
Earl Lee2e463fb2025-04-17 11:22:22 -0700807// initConvo initializes the conversation.
808// It must not be called until all agent fields are initialized,
809// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700810func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700811 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700812 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700813 convo.PromptCaching = true
814 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000815 convo.SystemPrompt = a.renderSystemPrompt()
Earl Lee2e463fb2025-04-17 11:22:22 -0700816
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000817 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
818 bashPermissionCheck := func(command string) error {
819 // Check if branch name is set
820 a.mu.Lock()
821 branchSet := a.branchName != ""
822 a.mu.Unlock()
823
824 // If branch is set, all commands are allowed
825 if branchSet {
826 return nil
827 }
828
829 // If branch is not set, check if this is a git commit command
830 willCommit, err := bashkit.WillRunGitCommit(command)
831 if err != nil {
832 // If there's an error checking, we should allow the command to proceed
833 return nil
834 }
835
836 // If it's a git commit and branch is not set, return an error
837 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000838 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000839 }
840
841 return nil
842 }
843
844 // Create a custom bash tool with the permission check
845 bashTool := claudetool.NewBashTool(bashPermissionCheck)
846
Earl Lee2e463fb2025-04-17 11:22:22 -0700847 // Register all tools with the conversation
848 // When adding, removing, or modifying tools here, double-check that the termui tool display
849 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000850
851 var browserTools []*llm.Tool
852 // Add browser tools if enabled
853 // if experiment.Enabled("browser") {
854 if true {
855 bTools, browserCleanup := browse.RegisterBrowserTools(a.config.Context)
856 // Add cleanup function to context cancel
857 go func() {
858 <-a.config.Context.Done()
859 browserCleanup()
860 }()
861 browserTools = bTools
862 }
863
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700864 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000865 bashTool, claudetool.Keyword,
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000866 claudetool.Think, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
Sean McCullough485afc62025-04-28 14:28:39 -0700867 a.codereview.Tool(), a.multipleChoiceTool(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700868 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000869
870 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -0700871 if a.config.UseAnthropicEdit {
872 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
873 } else {
874 convo.Tools = append(convo.Tools, claudetool.Patch)
875 }
876 convo.Listener = a
877 return convo
878}
879
Sean McCullough485afc62025-04-28 14:28:39 -0700880func (a *Agent) multipleChoiceTool() *llm.Tool {
881 ret := &llm.Tool{
882 Name: "multiplechoice",
883 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 +0000884 EndsTurn: true,
Sean McCullough485afc62025-04-28 14:28:39 -0700885 InputSchema: json.RawMessage(`{
886 "type": "object",
887 "description": "The question and a list of answers you would expect the user to choose from.",
888 "properties": {
889 "question": {
890 "type": "string",
891 "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?'"
892 },
893 "responseOptions": {
894 "type": "array",
895 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
896 "items": {
897 "type": "object",
898 "properties": {
899 "caption": {
900 "type": "string",
901 "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'"
902 },
903 "responseText": {
904 "type": "string",
905 "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'"
906 }
907 },
908 "required": ["caption", "responseText"]
909 }
910 }
911 },
912 "required": ["question", "responseOptions"]
913}`),
914 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
915 // The Run logic for "multiplchoice" tool is a no-op on the server.
916 // The UI will present a list of options for the user to select from,
917 // and that's it as far as "executing" the tool_use goes.
918 // When the user *does* select one of the presented options, that
919 // responseText gets sent as a chat message on behalf of the user.
920 return "end your turn and wait for the user to respond", nil
921 },
922 }
923 return ret
924}
925
926type MultipleChoiceOption struct {
927 Caption string `json:"caption"`
928 ResponseText string `json:"responseText"`
929}
930
931type MultipleChoiceParams struct {
932 Question string `json:"question"`
933 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
934}
935
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000936// branchExists reports whether branchName exists, either locally or in well-known remotes.
937func branchExists(dir, branchName string) bool {
938 refs := []string{
939 "refs/heads/",
940 "refs/remotes/origin/",
941 "refs/remotes/sketch-host/",
942 }
943 for _, ref := range refs {
944 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
945 cmd.Dir = dir
946 if cmd.Run() == nil { // exit code 0 means branch exists
947 return true
948 }
949 }
950 return false
951}
952
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000953func (a *Agent) titleTool() *llm.Tool {
954 description := `Sets the conversation title.`
955 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -0700956 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000957 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -0700958 InputSchema: json.RawMessage(`{
959 "type": "object",
960 "properties": {
961 "title": {
962 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000963 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -0700964 }
965 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000966 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -0700967}`),
968 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
969 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000970 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700971 }
972 if err := json.Unmarshal(input, &params); err != nil {
973 return "", err
974 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000975
976 // We don't allow changing the title once set to be consistent with the previous behavior
977 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700978 t := a.Title()
979 if t != "" {
980 return "", fmt.Errorf("title already set to: %s", t)
981 }
982
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700983 if params.Title == "" {
984 return "", fmt.Errorf("title parameter cannot be empty")
985 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000986
987 a.SetTitle(params.Title)
988 response := fmt.Sprintf("Title set to %q", params.Title)
989 return response, nil
990 },
991 }
992 return titleTool
993}
994
995func (a *Agent) precommitTool() *llm.Tool {
996 description := `Creates a git branch for tracking work. MANDATORY: You must use this tool before making any git commits.`
997 if experiment.Enabled("precommit") {
998 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.`
999 }
1000 preCommit := &llm.Tool{
1001 Name: "precommit",
1002 Description: description,
1003 InputSchema: json.RawMessage(`{
1004 "type": "object",
1005 "properties": {
1006 "branch_name": {
1007 "type": "string",
1008 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1009 }
1010 },
1011 "required": ["branch_name"]
1012}`),
1013 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
1014 var params struct {
1015 BranchName string `json:"branch_name"`
1016 }
1017 if err := json.Unmarshal(input, &params); err != nil {
1018 return "", err
1019 }
1020
1021 b := a.BranchName()
1022 if b != "" {
1023 return "", fmt.Errorf("branch already set to: %s", b)
1024 }
1025
1026 if params.BranchName == "" {
1027 return "", fmt.Errorf("branch_name parameter cannot be empty")
1028 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001029 if params.BranchName != cleanBranchName(params.BranchName) {
1030 return "", fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
1031 }
1032 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001033 if branchExists(a.workingDir, branchName) {
1034 return "", fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
1035 }
1036
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001037 a.SetBranch(branchName)
1038 response := fmt.Sprintf("Branch name set to %q", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001039
1040 if experiment.Enabled("precommit") {
1041 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1042 if err != nil {
1043 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1044 }
1045 if len(styleHint) > 0 {
1046 response += "\n\n" + styleHint
1047 }
1048 }
1049
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001050 return response, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001051 },
1052 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001053 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001054}
1055
1056func (a *Agent) Ready() <-chan struct{} {
1057 return a.ready
1058}
1059
1060func (a *Agent) UserMessage(ctx context.Context, msg string) {
1061 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1062 a.inbox <- msg
1063}
1064
Sean McCullough485afc62025-04-28 14:28:39 -07001065func (a *Agent) ToolResultMessage(ctx context.Context, toolCallID, msg string) {
1066 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg, ToolCallId: toolCallID})
1067 a.inbox <- msg
1068}
1069
Earl Lee2e463fb2025-04-17 11:22:22 -07001070func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1071 return a.convo.CancelToolUse(toolUseID, cause)
1072}
1073
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001074func (a *Agent) CancelTurn(cause error) {
1075 a.cancelTurnMu.Lock()
1076 defer a.cancelTurnMu.Unlock()
1077 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001078 // Force state transition to cancelled state
1079 ctx := a.config.Context
1080 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001081 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001082 }
1083}
1084
1085func (a *Agent) Loop(ctxOuter context.Context) {
1086 for {
1087 select {
1088 case <-ctxOuter.Done():
1089 return
1090 default:
1091 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001092 a.cancelTurnMu.Lock()
1093 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001094 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001095 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001096 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001097 a.cancelTurn = cancel
1098 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001099 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1100 if err != nil {
1101 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1102 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001103 cancel(nil)
1104 }
1105 }
1106}
1107
1108func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1109 if m.Timestamp.IsZero() {
1110 m.Timestamp = time.Now()
1111 }
1112
1113 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1114 if m.EndOfTurn && m.Type == AgentMessageType {
1115 turnDuration := time.Since(a.startOfTurn)
1116 m.TurnDuration = &turnDuration
1117 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1118 }
1119
Earl Lee2e463fb2025-04-17 11:22:22 -07001120 a.mu.Lock()
1121 defer a.mu.Unlock()
1122 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001123 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001124 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001125
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001126 // Notify all subscribers
1127 for _, ch := range a.subscribers {
1128 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001129 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001130}
1131
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001132func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1133 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001134 if block {
1135 select {
1136 case <-ctx.Done():
1137 return m, ctx.Err()
1138 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001139 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001140 }
1141 }
1142 for {
1143 select {
1144 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001145 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001146 default:
1147 return m, nil
1148 }
1149 }
1150}
1151
Sean McCullough885a16a2025-04-30 02:49:25 +00001152// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001153func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001154 // Reset the start of turn time
1155 a.startOfTurn = time.Now()
1156
Sean McCullough96b60dd2025-04-30 09:49:10 -07001157 // Transition to waiting for user input state
1158 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1159
Sean McCullough885a16a2025-04-30 02:49:25 +00001160 // Process initial user message
1161 initialResp, err := a.processUserMessage(ctx)
1162 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001163 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001164 return err
1165 }
1166
1167 // Handle edge case where both initialResp and err are nil
1168 if initialResp == nil {
1169 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001170 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1171
Sean McCullough9f4b8082025-04-30 17:34:07 +00001172 a.pushToOutbox(ctx, errorMessage(err))
1173 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001174 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001175
Earl Lee2e463fb2025-04-17 11:22:22 -07001176 // We do this as we go, but let's also do it at the end of the turn
1177 defer func() {
1178 if _, err := a.handleGitCommits(ctx); err != nil {
1179 // Just log the error, don't stop execution
1180 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1181 }
1182 }()
1183
Sean McCullougha1e0e492025-05-01 10:51:08 -07001184 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001185 resp := initialResp
1186 for {
1187 // Check if we are over budget
1188 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001189 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001190 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001191 }
1192
1193 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001194 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001195 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001196 break
1197 }
1198
Sean McCullough96b60dd2025-04-30 09:49:10 -07001199 // Transition to tool use requested state
1200 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1201
Sean McCullough885a16a2025-04-30 02:49:25 +00001202 // Handle tool execution
1203 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1204 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001205 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001206 }
1207
Sean McCullougha1e0e492025-05-01 10:51:08 -07001208 if toolResp == nil {
1209 return fmt.Errorf("cannot continue conversation with a nil tool response")
1210 }
1211
Sean McCullough885a16a2025-04-30 02:49:25 +00001212 // Set the response for the next iteration
1213 resp = toolResp
1214 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001215
1216 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001217}
1218
1219// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001220func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001221 // Wait for at least one message from the user
1222 msgs, err := a.GatherMessages(ctx, true)
1223 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001224 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001225 return nil, err
1226 }
1227
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001228 userMessage := llm.Message{
1229 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001230 Content: msgs,
1231 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001232
Sean McCullough96b60dd2025-04-30 09:49:10 -07001233 // Transition to sending to LLM state
1234 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1235
Sean McCullough885a16a2025-04-30 02:49:25 +00001236 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001237 resp, err := a.convo.SendMessage(userMessage)
1238 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001239 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001240 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001241 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001242 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001243
Sean McCullough96b60dd2025-04-30 09:49:10 -07001244 // Transition to processing LLM response state
1245 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1246
Sean McCullough885a16a2025-04-30 02:49:25 +00001247 return resp, nil
1248}
1249
1250// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001251func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1252 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001253 cancelled := false
1254
Sean McCullough96b60dd2025-04-30 09:49:10 -07001255 // Transition to checking for cancellation state
1256 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1257
Sean McCullough885a16a2025-04-30 02:49:25 +00001258 // Check if the operation was cancelled by the user
1259 select {
1260 case <-ctx.Done():
1261 // Don't actually run any of the tools, but rather build a response
1262 // for each tool_use message letting the LLM know that user canceled it.
1263 var err error
1264 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001265 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001266 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001267 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001268 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001269 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001270 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001271 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001272 // Transition to running tool state
1273 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1274
Sean McCullough885a16a2025-04-30 02:49:25 +00001275 // Add working directory to context for tool execution
1276 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1277
1278 // Execute the tools
1279 var err error
1280 results, err = a.convo.ToolResultContents(ctx, resp)
1281 if ctx.Err() != nil { // e.g. the user canceled the operation
1282 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001283 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001284 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001285 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001286 a.pushToOutbox(ctx, errorMessage(err))
1287 }
1288 }
1289
1290 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001291 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001292 autoqualityMessages := a.processGitChanges(ctx)
1293
1294 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001295 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001296 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001297 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001298 return false, nil
1299 }
1300
1301 // Continue the conversation with tool results and any user messages
1302 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1303}
1304
1305// processGitChanges checks for new git commits and runs autoformatters if needed
1306func (a *Agent) processGitChanges(ctx context.Context) []string {
1307 // Check for git commits after tool execution
1308 newCommits, err := a.handleGitCommits(ctx)
1309 if err != nil {
1310 // Just log the error, don't stop execution
1311 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1312 return nil
1313 }
1314
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001315 // Run mechanical checks if there was exactly one new commit.
1316 if len(newCommits) != 1 {
1317 return nil
1318 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001319 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001320 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1321 msg := a.codereview.RunMechanicalChecks(ctx)
1322 if msg != "" {
1323 a.pushToOutbox(ctx, AgentMessage{
1324 Type: AutoMessageType,
1325 Content: msg,
1326 Timestamp: time.Now(),
1327 })
1328 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001329 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001330
1331 return autoqualityMessages
1332}
1333
1334// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001335func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001336 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001337 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001338 msgs, err := a.GatherMessages(ctx, false)
1339 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001340 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001341 return false, nil
1342 }
1343
1344 // Inject any auto-generated messages from quality checks
1345 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001346 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001347 }
1348
1349 // Handle cancellation by appending a message about it
1350 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001351 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001352 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001353 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001354 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1355 } else if err := a.convo.OverBudget(); err != nil {
1356 // Handle budget issues by appending a message about it
1357 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 -07001358 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001359 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1360 }
1361
1362 // Combine tool results with user messages
1363 results = append(results, msgs...)
1364
1365 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001366 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001367 resp, err := a.convo.SendMessage(llm.Message{
1368 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001369 Content: results,
1370 })
1371 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001372 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001373 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1374 return true, nil // Return true to continue the conversation, but with no response
1375 }
1376
Sean McCullough96b60dd2025-04-30 09:49:10 -07001377 // Transition back to processing LLM response
1378 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1379
Sean McCullough885a16a2025-04-30 02:49:25 +00001380 if cancelled {
1381 return false, nil
1382 }
1383
1384 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001385}
1386
1387func (a *Agent) overBudget(ctx context.Context) error {
1388 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001389 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001390 m := budgetMessage(err)
1391 m.Content = m.Content + "\n\nBudget reset."
1392 a.pushToOutbox(ctx, budgetMessage(err))
1393 a.convo.ResetBudget(a.originalBudget)
1394 return err
1395 }
1396 return nil
1397}
1398
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001399func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001400 // Collect all text content
1401 var allText strings.Builder
1402 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001403 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001404 if allText.Len() > 0 {
1405 allText.WriteString("\n\n")
1406 }
1407 allText.WriteString(content.Text)
1408 }
1409 }
1410 return allText.String()
1411}
1412
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001413func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001414 a.mu.Lock()
1415 defer a.mu.Unlock()
1416 return a.convo.CumulativeUsage()
1417}
1418
Earl Lee2e463fb2025-04-17 11:22:22 -07001419// Diff returns a unified diff of changes made since the agent was instantiated.
1420func (a *Agent) Diff(commit *string) (string, error) {
1421 if a.initialCommit == "" {
1422 return "", fmt.Errorf("no initial commit reference available")
1423 }
1424
1425 // Find the repository root
1426 ctx := context.Background()
1427
1428 // If a specific commit hash is provided, show just that commit's changes
1429 if commit != nil && *commit != "" {
1430 // Validate that the commit looks like a valid git SHA
1431 if !isValidGitSHA(*commit) {
1432 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1433 }
1434
1435 // Get the diff for just this commit
1436 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1437 cmd.Dir = a.repoRoot
1438 output, err := cmd.CombinedOutput()
1439 if err != nil {
1440 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1441 }
1442 return string(output), nil
1443 }
1444
1445 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1446 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1447 cmd.Dir = a.repoRoot
1448 output, err := cmd.CombinedOutput()
1449 if err != nil {
1450 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1451 }
1452
1453 return string(output), nil
1454}
1455
1456// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1457func (a *Agent) InitialCommit() string {
1458 return a.initialCommit
1459}
1460
1461// handleGitCommits() highlights new commits to the user. When running
1462// under docker, new HEADs are pushed to a branch according to the title.
1463func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1464 if a.repoRoot == "" {
1465 return nil, nil
1466 }
1467
1468 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1469 if err != nil {
1470 return nil, err
1471 }
1472 if head == a.lastHEAD {
1473 return nil, nil // nothing to do
1474 }
1475 defer func() {
1476 a.lastHEAD = head
1477 }()
1478
1479 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1480 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1481 // to the last 100 commits.
1482 var commits []*GitCommit
1483
1484 // Get commits since the initial commit
1485 // Format: <hash>\0<subject>\0<body>\0
1486 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1487 // Limit to 100 commits to avoid overwhelming the user
1488 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1489 cmd.Dir = a.repoRoot
1490 output, err := cmd.Output()
1491 if err != nil {
1492 return nil, fmt.Errorf("failed to get git log: %w", err)
1493 }
1494
1495 // Parse git log output and filter out already seen commits
1496 parsedCommits := parseGitLog(string(output))
1497
1498 var headCommit *GitCommit
1499
1500 // Filter out commits we've already seen
1501 for _, commit := range parsedCommits {
1502 if commit.Hash == head {
1503 headCommit = &commit
1504 }
1505
1506 // Skip if we've seen this commit before. If our head has changed, always include that.
1507 if a.seenCommits[commit.Hash] && commit.Hash != head {
1508 continue
1509 }
1510
1511 // Mark this commit as seen
1512 a.seenCommits[commit.Hash] = true
1513
1514 // Add to our list of new commits
1515 commits = append(commits, &commit)
1516 }
1517
1518 if a.gitRemoteAddr != "" {
1519 if headCommit == nil {
1520 // I think this can only happen if we have a bug or if there's a race.
1521 headCommit = &GitCommit{}
1522 headCommit.Hash = head
1523 headCommit.Subject = "unknown"
1524 commits = append(commits, headCommit)
1525 }
1526
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001527 branch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
Earl Lee2e463fb2025-04-17 11:22:22 -07001528
1529 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1530 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1531 // then use push with lease to replace.
1532 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1533 cmd.Dir = a.workingDir
1534 if out, err := cmd.CombinedOutput(); err != nil {
1535 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1536 } else {
1537 headCommit.PushedBranch = branch
1538 }
1539 }
1540
1541 // If we found new commits, create a message
1542 if len(commits) > 0 {
1543 msg := AgentMessage{
1544 Type: CommitMessageType,
1545 Timestamp: time.Now(),
1546 Commits: commits,
1547 }
1548 a.pushToOutbox(ctx, msg)
1549 }
1550 return commits, nil
1551}
1552
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001553func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001554 return strings.Map(func(r rune) rune {
1555 // lowercase
1556 if r >= 'A' && r <= 'Z' {
1557 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001558 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001559 // replace spaces with dashes
1560 if r == ' ' {
1561 return '-'
1562 }
1563 // allow alphanumerics and dashes
1564 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1565 return r
1566 }
1567 return -1
1568 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001569}
1570
1571// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1572// and returns an array of GitCommit structs.
1573func parseGitLog(output string) []GitCommit {
1574 var commits []GitCommit
1575
1576 // No output means no commits
1577 if len(output) == 0 {
1578 return commits
1579 }
1580
1581 // Split by NULL byte
1582 parts := strings.Split(output, "\x00")
1583
1584 // Process in triplets (hash, subject, body)
1585 for i := 0; i < len(parts); i++ {
1586 // Skip empty parts
1587 if parts[i] == "" {
1588 continue
1589 }
1590
1591 // This should be a hash
1592 hash := strings.TrimSpace(parts[i])
1593
1594 // Make sure we have at least a subject part available
1595 if i+1 >= len(parts) {
1596 break // No more parts available
1597 }
1598
1599 // Get the subject
1600 subject := strings.TrimSpace(parts[i+1])
1601
1602 // Get the body if available
1603 body := ""
1604 if i+2 < len(parts) {
1605 body = strings.TrimSpace(parts[i+2])
1606 }
1607
1608 // Skip to the next triplet
1609 i += 2
1610
1611 commits = append(commits, GitCommit{
1612 Hash: hash,
1613 Subject: subject,
1614 Body: body,
1615 })
1616 }
1617
1618 return commits
1619}
1620
1621func repoRoot(ctx context.Context, dir string) (string, error) {
1622 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1623 stderr := new(strings.Builder)
1624 cmd.Stderr = stderr
1625 cmd.Dir = dir
1626 out, err := cmd.Output()
1627 if err != nil {
1628 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1629 }
1630 return strings.TrimSpace(string(out)), nil
1631}
1632
1633func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1634 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1635 stderr := new(strings.Builder)
1636 cmd.Stderr = stderr
1637 cmd.Dir = dir
1638 out, err := cmd.Output()
1639 if err != nil {
1640 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1641 }
1642 // TODO: validate that out is valid hex
1643 return strings.TrimSpace(string(out)), nil
1644}
1645
1646// isValidGitSHA validates if a string looks like a valid git SHA hash.
1647// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1648func isValidGitSHA(sha string) bool {
1649 // Git SHA must be a hexadecimal string with at least 4 characters
1650 if len(sha) < 4 || len(sha) > 40 {
1651 return false
1652 }
1653
1654 // Check if the string only contains hexadecimal characters
1655 for _, char := range sha {
1656 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1657 return false
1658 }
1659 }
1660
1661 return true
1662}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001663
1664// getGitOrigin returns the URL of the git remote 'origin' if it exists
1665func getGitOrigin(ctx context.Context, dir string) string {
1666 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1667 cmd.Dir = dir
1668 stderr := new(strings.Builder)
1669 cmd.Stderr = stderr
1670 out, err := cmd.Output()
1671 if err != nil {
1672 return ""
1673 }
1674 return strings.TrimSpace(string(out))
1675}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001676
1677func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1678 cmd := exec.CommandContext(ctx, "git", "stash")
1679 cmd.Dir = workingDir
1680 if out, err := cmd.CombinedOutput(); err != nil {
1681 return fmt.Errorf("git stash: %s: %v", out, err)
1682 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001683 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001684 cmd.Dir = workingDir
1685 if out, err := cmd.CombinedOutput(); err != nil {
1686 return fmt.Errorf("git fetch: %s: %w", out, err)
1687 }
1688 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1689 cmd.Dir = workingDir
1690 if out, err := cmd.CombinedOutput(); err != nil {
1691 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1692 }
1693 a.lastHEAD = revision
1694 a.initialCommit = revision
1695 return nil
1696}
1697
1698func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1699 a.mu.Lock()
1700 a.title = ""
1701 a.firstMessageIndex = len(a.history)
1702 a.convo = a.initConvo()
1703 gitReset := func() error {
1704 if a.config.InDocker && rev != "" {
1705 err := a.initGitRevision(ctx, a.workingDir, rev)
1706 if err != nil {
1707 return err
1708 }
1709 } else if !a.config.InDocker && rev != "" {
1710 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1711 }
1712 return nil
1713 }
1714 err := gitReset()
1715 a.mu.Unlock()
1716 if err != nil {
1717 a.pushToOutbox(a.config.Context, errorMessage(err))
1718 }
1719
1720 a.pushToOutbox(a.config.Context, AgentMessage{
1721 Type: AgentMessageType, Content: "Conversation restarted.",
1722 })
1723 if initialPrompt != "" {
1724 a.UserMessage(ctx, initialPrompt)
1725 }
1726 return nil
1727}
1728
1729func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1730 msg := `The user has requested a suggestion for a re-prompt.
1731
1732 Given the current conversation thus far, suggest a re-prompt that would
1733 capture the instructions and feedback so far, as well as any
1734 research or other information that would be helpful in implementing
1735 the task.
1736
1737 Reply with ONLY the reprompt text.
1738 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001739 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001740 // By doing this in a subconversation, the agent doesn't call tools (because
1741 // there aren't any), and there's not a concurrency risk with on-going other
1742 // outstanding conversations.
1743 convo := a.convo.SubConvoWithHistory()
1744 resp, err := convo.SendMessage(userMessage)
1745 if err != nil {
1746 a.pushToOutbox(ctx, errorMessage(err))
1747 return "", err
1748 }
1749 textContent := collectTextContent(resp)
1750 return textContent, nil
1751}
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001752
1753// systemPromptData contains the data used to render the system prompt template
1754type systemPromptData struct {
1755 EditPrompt string
1756 ClientGOOS string
1757 ClientGOARCH string
1758 WorkingDir string
1759 RepoRoot string
1760 InitialCommit string
1761}
1762
1763// renderSystemPrompt renders the system prompt template.
1764func (a *Agent) renderSystemPrompt() string {
1765 // Determine the appropriate edit prompt based on config
1766 var editPrompt string
1767 if a.config.UseAnthropicEdit {
1768 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."
1769 } else {
1770 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1771 }
1772
1773 data := systemPromptData{
1774 EditPrompt: editPrompt,
1775 ClientGOOS: a.config.ClientGOOS,
1776 ClientGOARCH: a.config.ClientGOARCH,
1777 WorkingDir: a.workingDir,
1778 RepoRoot: a.repoRoot,
1779 InitialCommit: a.initialCommit,
1780 }
1781
1782 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1783 if err != nil {
1784 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1785 }
1786 buf := new(strings.Builder)
1787 err = tmpl.Execute(buf, data)
1788 if err != nil {
1789 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1790 }
1791 return buf.String()
1792}