blob: 2f0e56dfe28853bbad4c5af71cbed5e77d11d783 [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"
18 "time"
19
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000020 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070021 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000022 "sketch.dev/claudetool/bashkit"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000023 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -070024 "sketch.dev/experiment"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070025 "sketch.dev/llm"
26 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070027)
28
29const (
30 userCancelMessage = "user requested agent to stop handling responses"
31)
32
Philip Zeyligerb7c58752025-05-01 10:10:17 -070033type MessageIterator interface {
34 // Next blocks until the next message is available. It may
35 // return nil if the underlying iterator context is done.
36 Next() *AgentMessage
37 Close()
38}
39
Earl Lee2e463fb2025-04-17 11:22:22 -070040type CodingAgent interface {
41 // Init initializes an agent inside a docker container.
42 Init(AgentInit) error
43
44 // Ready returns a channel closed after Init successfully called.
45 Ready() <-chan struct{}
46
47 // URL reports the HTTP URL of this agent.
48 URL() string
49
50 // UserMessage enqueues a message to the agent and returns immediately.
51 UserMessage(ctx context.Context, msg string)
52
Philip Zeyligerb7c58752025-05-01 10:10:17 -070053 // Returns an iterator that finishes when the context is done and
54 // starts with the given message index.
55 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070056
57 // Loop begins the agent loop returns only when ctx is cancelled.
58 Loop(ctx context.Context)
59
Sean McCulloughedc88dc2025-04-30 02:55:01 +000060 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070061
62 CancelToolUse(toolUseID string, cause error) error
63
64 // Returns a subset of the agent's message history.
65 Messages(start int, end int) []AgentMessage
66
67 // Returns the current number of messages in the history
68 MessageCount() int
69
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070070 TotalUsage() conversation.CumulativeUsage
71 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070072
Earl Lee2e463fb2025-04-17 11:22:22 -070073 WorkingDir() string
74
75 // Diff returns a unified diff of changes made since the agent was instantiated.
76 // If commit is non-nil, it shows the diff for just that specific commit.
77 Diff(commit *string) (string, error)
78
79 // InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
80 InitialCommit() string
81
82 // Title returns the current title of the conversation.
83 Title() string
84
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000085 // BranchName returns the git branch name for the conversation.
86 BranchName() string
87
Earl Lee2e463fb2025-04-17 11:22:22 -070088 // OS returns the operating system of the client.
89 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +000090
Philip Zeyligerc72fff52025-04-29 20:17:54 +000091 // SessionID returns the unique session identifier.
92 SessionID() string
93
Philip Zeyliger99a9a022025-04-27 15:15:25 +000094 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
95 OutstandingLLMCallCount() int
96
97 // OutstandingToolCalls returns the names of outstanding tool calls.
98 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +000099 OutsideOS() string
100 OutsideHostname() string
101 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000102 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000103 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
104 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700105
106 // RestartConversation resets the conversation history
107 RestartConversation(ctx context.Context, rev string, initialPrompt string) error
108 // SuggestReprompt suggests a re-prompt based on the current conversation.
109 SuggestReprompt(ctx context.Context) (string, error)
110 // IsInContainer returns true if the agent is running in a container
111 IsInContainer() bool
112 // FirstMessageIndex returns the index of the first message in the current conversation
113 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700114
115 CurrentStateName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700116}
117
118type CodingAgentMessageType string
119
120const (
121 UserMessageType CodingAgentMessageType = "user"
122 AgentMessageType CodingAgentMessageType = "agent"
123 ErrorMessageType CodingAgentMessageType = "error"
124 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
125 ToolUseMessageType CodingAgentMessageType = "tool"
126 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
127 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
128
129 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
130)
131
132type AgentMessage struct {
133 Type CodingAgentMessageType `json:"type"`
134 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
135 EndOfTurn bool `json:"end_of_turn"`
136
137 Content string `json:"content"`
138 ToolName string `json:"tool_name,omitempty"`
139 ToolInput string `json:"input,omitempty"`
140 ToolResult string `json:"tool_result,omitempty"`
141 ToolError bool `json:"tool_error,omitempty"`
142 ToolCallId string `json:"tool_call_id,omitempty"`
143
144 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
145 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
146
Sean McCulloughd9f13372025-04-21 15:08:49 -0700147 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
148 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
149
Earl Lee2e463fb2025-04-17 11:22:22 -0700150 // Commits is a list of git commits for a commit message
151 Commits []*GitCommit `json:"commits,omitempty"`
152
153 Timestamp time.Time `json:"timestamp"`
154 ConversationID string `json:"conversation_id"`
155 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700156 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700157
158 // Message timing information
159 StartTime *time.Time `json:"start_time,omitempty"`
160 EndTime *time.Time `json:"end_time,omitempty"`
161 Elapsed *time.Duration `json:"elapsed,omitempty"`
162
163 // Turn duration - the time taken for a complete agent turn
164 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
165
166 Idx int `json:"idx"`
167}
168
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700169// SetConvo sets m.ConversationID and m.ParentConversationID based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700170func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700171 if convo == nil {
172 m.ConversationID = ""
173 m.ParentConversationID = nil
174 return
175 }
176 m.ConversationID = convo.ID
177 if convo.Parent != nil {
178 m.ParentConversationID = &convo.Parent.ID
179 }
180}
181
Earl Lee2e463fb2025-04-17 11:22:22 -0700182// GitCommit represents a single git commit for a commit message
183type GitCommit struct {
184 Hash string `json:"hash"` // Full commit hash
185 Subject string `json:"subject"` // Commit subject line
186 Body string `json:"body"` // Full commit message body
187 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
188}
189
190// ToolCall represents a single tool call within an agent message
191type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700192 Name string `json:"name"`
193 Input string `json:"input"`
194 ToolCallId string `json:"tool_call_id"`
195 ResultMessage *AgentMessage `json:"result_message,omitempty"`
196 Args string `json:"args,omitempty"`
197 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700198}
199
200func (a *AgentMessage) Attr() slog.Attr {
201 var attrs []any = []any{
202 slog.String("type", string(a.Type)),
203 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700204 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700205 if a.EndOfTurn {
206 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
207 }
208 if a.Content != "" {
209 attrs = append(attrs, slog.String("content", a.Content))
210 }
211 if a.ToolName != "" {
212 attrs = append(attrs, slog.String("tool_name", a.ToolName))
213 }
214 if a.ToolInput != "" {
215 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
216 }
217 if a.Elapsed != nil {
218 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
219 }
220 if a.TurnDuration != nil {
221 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
222 }
223 if a.ToolResult != "" {
224 attrs = append(attrs, slog.String("tool_result", a.ToolResult))
225 }
226 if a.ToolError {
227 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
228 }
229 if len(a.ToolCalls) > 0 {
230 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
231 for i, tc := range a.ToolCalls {
232 toolCallAttrs = append(toolCallAttrs, slog.Group(
233 fmt.Sprintf("tool_call_%d", i),
234 slog.String("name", tc.Name),
235 slog.String("input", tc.Input),
236 ))
237 }
238 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
239 }
240 if a.ConversationID != "" {
241 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
242 }
243 if a.ParentConversationID != nil {
244 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
245 }
246 if a.Usage != nil && !a.Usage.IsZero() {
247 attrs = append(attrs, a.Usage.Attr())
248 }
249 // TODO: timestamp, convo ids, idx?
250 return slog.Group("agent_message", attrs...)
251}
252
253func errorMessage(err error) AgentMessage {
254 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
255 if os.Getenv(("DEBUG")) == "1" {
256 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
257 }
258
259 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
260}
261
262func budgetMessage(err error) AgentMessage {
263 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
264}
265
266// ConvoInterface defines the interface for conversation interactions
267type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700268 CumulativeUsage() conversation.CumulativeUsage
269 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700270 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700271 SendMessage(message llm.Message) (*llm.Response, error)
272 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700273 GetID() string
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700274 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error)
275 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700276 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700277 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700278}
279
280type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700281 convo ConvoInterface
282 config AgentConfig // config for this agent
283 workingDir string
284 repoRoot string // workingDir may be a subdir of repoRoot
285 url string
286 firstMessageIndex int // index of the first message in the current conversation
287 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
288 initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
289 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000290 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700291 ready chan struct{} // closed when the agent is initialized (only when under docker)
292 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700293 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700294 title string
295 branchName string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000296 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700297 // State machine to track agent state
298 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000299 // Outside information
300 outsideHostname string
301 outsideOS string
302 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000303 // URL of the git remote 'origin' if it exists
304 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700305
306 // Time when the current turn started (reset at the beginning of InnerLoop)
307 startOfTurn time.Time
308
309 // Inbox - for messages from the user to the agent.
310 // sent on by UserMessage
311 // . e.g. when user types into the chat textarea
312 // read from by GatherMessages
313 inbox chan string
314
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000315 // protects cancelTurn
316 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700317 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000318 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700319
320 // protects following
321 mu sync.Mutex
322
323 // Stores all messages for this agent
324 history []AgentMessage
325
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700326 // Iterators add themselves here when they're ready to be notified of new messages.
327 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700328
329 // Track git commits we've already seen (by hash)
330 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000331
332 // Track outstanding LLM call IDs
333 outstandingLLMCalls map[string]struct{}
334
335 // Track outstanding tool calls by ID with their names
336 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700337}
338
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700339// NewIterator implements CodingAgent.
340func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
341 a.mu.Lock()
342 defer a.mu.Unlock()
343
344 return &MessageIteratorImpl{
345 agent: a,
346 ctx: ctx,
347 nextMessageIdx: nextMessageIdx,
348 ch: make(chan *AgentMessage, 100),
349 }
350}
351
352type MessageIteratorImpl struct {
353 agent *Agent
354 ctx context.Context
355 nextMessageIdx int
356 ch chan *AgentMessage
357 subscribed bool
358}
359
360func (m *MessageIteratorImpl) Close() {
361 m.agent.mu.Lock()
362 defer m.agent.mu.Unlock()
363 // Delete ourselves from the subscribers list
364 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
365 return x == m.ch
366 })
367 close(m.ch)
368}
369
370func (m *MessageIteratorImpl) Next() *AgentMessage {
371 // We avoid subscription at creation to let ourselves catch up to "current state"
372 // before subscribing.
373 if !m.subscribed {
374 m.agent.mu.Lock()
375 if m.nextMessageIdx < len(m.agent.history) {
376 msg := &m.agent.history[m.nextMessageIdx]
377 m.nextMessageIdx++
378 m.agent.mu.Unlock()
379 return msg
380 }
381 // The next message doesn't exist yet, so let's subscribe
382 m.agent.subscribers = append(m.agent.subscribers, m.ch)
383 m.subscribed = true
384 m.agent.mu.Unlock()
385 }
386
387 for {
388 select {
389 case <-m.ctx.Done():
390 m.agent.mu.Lock()
391 // Delete ourselves from the subscribers list
392 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
393 return x == m.ch
394 })
395 m.subscribed = false
396 m.agent.mu.Unlock()
397 return nil
398 case msg, ok := <-m.ch:
399 if !ok {
400 // Close may have been called
401 return nil
402 }
403 if msg.Idx == m.nextMessageIdx {
404 m.nextMessageIdx++
405 return msg
406 }
407 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
408 panic("out of order message")
409 }
410 }
411}
412
Sean McCulloughd9d45812025-04-30 16:53:41 -0700413// Assert that Agent satisfies the CodingAgent interface.
414var _ CodingAgent = &Agent{}
415
416// StateName implements CodingAgent.
417func (a *Agent) CurrentStateName() string {
418 if a.stateMachine == nil {
419 return ""
420 }
421 return a.stateMachine.currentState.String()
422}
423
Earl Lee2e463fb2025-04-17 11:22:22 -0700424func (a *Agent) URL() string { return a.url }
425
426// Title returns the current title of the conversation.
427// If no title has been set, returns an empty string.
428func (a *Agent) Title() string {
429 a.mu.Lock()
430 defer a.mu.Unlock()
431 return a.title
432}
433
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000434// BranchName returns the git branch name for the conversation.
435func (a *Agent) BranchName() string {
436 a.mu.Lock()
437 defer a.mu.Unlock()
438 return a.branchName
439}
440
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000441// OutstandingLLMCallCount returns the number of outstanding LLM calls.
442func (a *Agent) OutstandingLLMCallCount() int {
443 a.mu.Lock()
444 defer a.mu.Unlock()
445 return len(a.outstandingLLMCalls)
446}
447
448// OutstandingToolCalls returns the names of outstanding tool calls.
449func (a *Agent) OutstandingToolCalls() []string {
450 a.mu.Lock()
451 defer a.mu.Unlock()
452
453 tools := make([]string, 0, len(a.outstandingToolCalls))
454 for _, toolName := range a.outstandingToolCalls {
455 tools = append(tools, toolName)
456 }
457 return tools
458}
459
Earl Lee2e463fb2025-04-17 11:22:22 -0700460// OS returns the operating system of the client.
461func (a *Agent) OS() string {
462 return a.config.ClientGOOS
463}
464
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000465func (a *Agent) SessionID() string {
466 return a.config.SessionID
467}
468
Philip Zeyliger18532b22025-04-23 21:11:46 +0000469// OutsideOS returns the operating system of the outside system.
470func (a *Agent) OutsideOS() string {
471 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000472}
473
Philip Zeyliger18532b22025-04-23 21:11:46 +0000474// OutsideHostname returns the hostname of the outside system.
475func (a *Agent) OutsideHostname() string {
476 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000477}
478
Philip Zeyliger18532b22025-04-23 21:11:46 +0000479// OutsideWorkingDir returns the working directory on the outside system.
480func (a *Agent) OutsideWorkingDir() string {
481 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000482}
483
484// GitOrigin returns the URL of the git remote 'origin' if it exists.
485func (a *Agent) GitOrigin() string {
486 return a.gitOrigin
487}
488
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000489func (a *Agent) OpenBrowser(url string) {
490 if !a.IsInContainer() {
491 browser.Open(url)
492 return
493 }
494 // We're in Docker, need to send a request to the Git server
495 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700496 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000497 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700498 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000499 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700500 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000501 return
502 }
503 defer resp.Body.Close()
504 if resp.StatusCode == http.StatusOK {
505 return
506 }
507 body, _ := io.ReadAll(resp.Body)
508 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
509}
510
Sean McCullough96b60dd2025-04-30 09:49:10 -0700511// CurrentState returns the current state of the agent's state machine.
512func (a *Agent) CurrentState() State {
513 return a.stateMachine.CurrentState()
514}
515
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700516func (a *Agent) IsInContainer() bool {
517 return a.config.InDocker
518}
519
520func (a *Agent) FirstMessageIndex() int {
521 a.mu.Lock()
522 defer a.mu.Unlock()
523 return a.firstMessageIndex
524}
525
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700526// SetTitleBranch sets the title and branch name of the conversation.
527func (a *Agent) SetTitleBranch(title, branchName string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700528 a.mu.Lock()
529 defer a.mu.Unlock()
530 a.title = title
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700531 a.branchName = branchName
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700532
533 // TODO: We could potentially notify listeners of a state change, but,
534 // realistically, a new message will be sent for the tool result as well.
Earl Lee2e463fb2025-04-17 11:22:22 -0700535}
536
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000537// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700538func (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 +0000539 // Track the tool call
540 a.mu.Lock()
541 a.outstandingToolCalls[id] = toolName
542 a.mu.Unlock()
543}
544
Earl Lee2e463fb2025-04-17 11:22:22 -0700545// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700546func (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 +0000547 // Remove the tool call from outstanding calls
548 a.mu.Lock()
549 delete(a.outstandingToolCalls, toolID)
550 a.mu.Unlock()
551
Earl Lee2e463fb2025-04-17 11:22:22 -0700552 m := AgentMessage{
553 Type: ToolUseMessageType,
554 Content: content.Text,
555 ToolResult: content.ToolResult,
556 ToolError: content.ToolError,
557 ToolName: toolName,
558 ToolInput: string(toolInput),
559 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700560 StartTime: content.ToolUseStartTime,
561 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700562 }
563
564 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700565 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
566 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700567 m.Elapsed = &elapsed
568 }
569
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700570 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700571 a.pushToOutbox(ctx, m)
572}
573
574// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700575func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000576 a.mu.Lock()
577 defer a.mu.Unlock()
578 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700579 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
580}
581
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700582// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700583// that need to be displayed (as well as tool calls that we send along when
584// they're done). (It would be reasonable to also mention tool calls when they're
585// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700586func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000587 // Remove the LLM call from outstanding calls
588 a.mu.Lock()
589 delete(a.outstandingLLMCalls, id)
590 a.mu.Unlock()
591
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700592 if resp == nil {
593 // LLM API call failed
594 m := AgentMessage{
595 Type: ErrorMessageType,
596 Content: "API call failed, type 'continue' to try again",
597 }
598 m.SetConvo(convo)
599 a.pushToOutbox(ctx, m)
600 return
601 }
602
Earl Lee2e463fb2025-04-17 11:22:22 -0700603 endOfTurn := false
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700604 if resp.StopReason != llm.StopReasonToolUse && convo.Parent == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700605 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000606 } else if resp.StopReason == llm.StopReasonToolUse {
607 // Check if any of the tool calls are for tools that should end the turn
608 for _, part := range resp.Content {
609 if part.Type == llm.ContentTypeToolUse {
610 // Find the tool by name
611 for _, tool := range convo.Tools {
612 if tool.Name == part.ToolName && tool.EndsTurn {
613 endOfTurn = true
614 break
615 }
616 }
617 if endOfTurn {
618 break
619 }
620 }
621 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700622 }
623 m := AgentMessage{
624 Type: AgentMessageType,
625 Content: collectTextContent(resp),
626 EndOfTurn: endOfTurn,
627 Usage: &resp.Usage,
628 StartTime: resp.StartTime,
629 EndTime: resp.EndTime,
630 }
631
632 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700633 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700634 var toolCalls []ToolCall
635 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700636 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700637 toolCalls = append(toolCalls, ToolCall{
638 Name: part.ToolName,
639 Input: string(part.ToolInput),
640 ToolCallId: part.ID,
641 })
642 }
643 }
644 m.ToolCalls = toolCalls
645 }
646
647 // Calculate the elapsed time if both start and end times are set
648 if resp.StartTime != nil && resp.EndTime != nil {
649 elapsed := resp.EndTime.Sub(*resp.StartTime)
650 m.Elapsed = &elapsed
651 }
652
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700653 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700654 a.pushToOutbox(ctx, m)
655}
656
657// WorkingDir implements CodingAgent.
658func (a *Agent) WorkingDir() string {
659 return a.workingDir
660}
661
662// MessageCount implements CodingAgent.
663func (a *Agent) MessageCount() int {
664 a.mu.Lock()
665 defer a.mu.Unlock()
666 return len(a.history)
667}
668
669// Messages implements CodingAgent.
670func (a *Agent) Messages(start int, end int) []AgentMessage {
671 a.mu.Lock()
672 defer a.mu.Unlock()
673 return slices.Clone(a.history[start:end])
674}
675
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700676func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700677 return a.originalBudget
678}
679
680// AgentConfig contains configuration for creating a new Agent.
681type AgentConfig struct {
682 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700683 Service llm.Service
684 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700685 GitUsername string
686 GitEmail string
687 SessionID string
688 ClientGOOS string
689 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700690 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700691 UseAnthropicEdit bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000692 // Outside information
693 OutsideHostname string
694 OutsideOS string
695 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700696}
697
698// NewAgent creates a new Agent.
699// It is not usable until Init() is called.
700func NewAgent(config AgentConfig) *Agent {
701 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000702 config: config,
703 ready: make(chan struct{}),
704 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700705 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000706 startedAt: time.Now(),
707 originalBudget: config.Budget,
708 seenCommits: make(map[string]bool),
709 outsideHostname: config.OutsideHostname,
710 outsideOS: config.OutsideOS,
711 outsideWorkingDir: config.OutsideWorkingDir,
712 outstandingLLMCalls: make(map[string]struct{}),
713 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700714 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700715 }
716 return agent
717}
718
719type AgentInit struct {
720 WorkingDir string
721 NoGit bool // only for testing
722
723 InDocker bool
724 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000725 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700726 GitRemoteAddr string
727 HostAddr string
728}
729
730func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700731 if a.convo != nil {
732 return fmt.Errorf("Agent.Init: already initialized")
733 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700734 ctx := a.config.Context
735 if ini.InDocker {
736 cmd := exec.CommandContext(ctx, "git", "stash")
737 cmd.Dir = ini.WorkingDir
738 if out, err := cmd.CombinedOutput(); err != nil {
739 return fmt.Errorf("git stash: %s: %v", out, err)
740 }
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700741 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
742 cmd.Dir = ini.WorkingDir
743 if out, err := cmd.CombinedOutput(); err != nil {
744 return fmt.Errorf("git remote add: %s: %v", out, err)
745 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000746 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700747 cmd.Dir = ini.WorkingDir
748 if out, err := cmd.CombinedOutput(); err != nil {
749 return fmt.Errorf("git fetch: %s: %w", out, err)
750 }
751 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
752 cmd.Dir = ini.WorkingDir
753 if out, err := cmd.CombinedOutput(); err != nil {
754 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
755 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700756 a.lastHEAD = ini.Commit
757 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000758 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700759 a.initialCommit = ini.Commit
760 if ini.HostAddr != "" {
761 a.url = "http://" + ini.HostAddr
762 }
763 }
764 a.workingDir = ini.WorkingDir
765
766 if !ini.NoGit {
767 repoRoot, err := repoRoot(ctx, a.workingDir)
768 if err != nil {
769 return fmt.Errorf("repoRoot: %w", err)
770 }
771 a.repoRoot = repoRoot
772
773 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
774 if err != nil {
775 return fmt.Errorf("resolveRef: %w", err)
776 }
777 a.initialCommit = commitHash
778
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000779 llmCodeReview := codereview.NoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700780 if experiment.Enabled("llm_review") {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000781 llmCodeReview = codereview.DoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700782 }
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000783 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit, llmCodeReview)
Earl Lee2e463fb2025-04-17 11:22:22 -0700784 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000785 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700786 }
787 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000788
789 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700790 }
791 a.lastHEAD = a.initialCommit
792 a.convo = a.initConvo()
793 close(a.ready)
794 return nil
795}
796
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700797//go:embed agent_system_prompt.txt
798var agentSystemPrompt string
799
Earl Lee2e463fb2025-04-17 11:22:22 -0700800// initConvo initializes the conversation.
801// It must not be called until all agent fields are initialized,
802// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700803func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700804 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700805 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700806 convo.PromptCaching = true
807 convo.Budget = a.config.Budget
808
809 var editPrompt string
810 if a.config.UseAnthropicEdit {
811 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."
812 } else {
813 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
814 }
815
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700816 convo.SystemPrompt = fmt.Sprintf(agentSystemPrompt, editPrompt, a.config.ClientGOOS, a.config.ClientGOARCH, a.workingDir, a.repoRoot, a.initialCommit)
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 {
839 return fmt.Errorf("you must use the title tool before making git commits")
840 }
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.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700851 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000852 bashTool, claudetool.Keyword,
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000853 claudetool.Think, a.preCommitTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
Sean McCullough485afc62025-04-28 14:28:39 -0700854 a.codereview.Tool(), a.multipleChoiceTool(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700855 }
856 if a.config.UseAnthropicEdit {
857 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
858 } else {
859 convo.Tools = append(convo.Tools, claudetool.Patch)
860 }
861 convo.Listener = a
862 return convo
863}
864
Sean McCullough485afc62025-04-28 14:28:39 -0700865func (a *Agent) multipleChoiceTool() *llm.Tool {
866 ret := &llm.Tool{
867 Name: "multiplechoice",
868 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 +0000869 EndsTurn: true,
Sean McCullough485afc62025-04-28 14:28:39 -0700870 InputSchema: json.RawMessage(`{
871 "type": "object",
872 "description": "The question and a list of answers you would expect the user to choose from.",
873 "properties": {
874 "question": {
875 "type": "string",
876 "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?'"
877 },
878 "responseOptions": {
879 "type": "array",
880 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
881 "items": {
882 "type": "object",
883 "properties": {
884 "caption": {
885 "type": "string",
886 "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'"
887 },
888 "responseText": {
889 "type": "string",
890 "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'"
891 }
892 },
893 "required": ["caption", "responseText"]
894 }
895 }
896 },
897 "required": ["question", "responseOptions"]
898}`),
899 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
900 // The Run logic for "multiplchoice" tool is a no-op on the server.
901 // The UI will present a list of options for the user to select from,
902 // and that's it as far as "executing" the tool_use goes.
903 // When the user *does* select one of the presented options, that
904 // responseText gets sent as a chat message on behalf of the user.
905 return "end your turn and wait for the user to respond", nil
906 },
907 }
908 return ret
909}
910
911type MultipleChoiceOption struct {
912 Caption string `json:"caption"`
913 ResponseText string `json:"responseText"`
914}
915
916type MultipleChoiceParams struct {
917 Question string `json:"question"`
918 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
919}
920
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000921// branchExists reports whether branchName exists, either locally or in well-known remotes.
922func branchExists(dir, branchName string) bool {
923 refs := []string{
924 "refs/heads/",
925 "refs/remotes/origin/",
926 "refs/remotes/sketch-host/",
927 }
928 for _, ref := range refs {
929 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
930 cmd.Dir = dir
931 if cmd.Run() == nil { // exit code 0 means branch exists
932 return true
933 }
934 }
935 return false
936}
937
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000938func (a *Agent) preCommitTool() *llm.Tool {
939 name := "title"
940 description := `Sets the conversation title and creates a git branch for tracking work. MANDATORY: You must use this tool before making any git commits.`
941 if experiment.Enabled("precommit") {
942 name = "precommit"
943 description = `Sets the conversation title, 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.`
944 }
945 preCommit := &llm.Tool{
946 Name: name,
947 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -0700948 InputSchema: json.RawMessage(`{
949 "type": "object",
950 "properties": {
951 "title": {
952 "type": "string",
Josh Bleecher Snyder250348e2025-04-30 10:31:28 -0700953 "description": "A concise title summarizing what this conversation is about, imperative tense preferred"
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700954 },
955 "branch_name": {
956 "type": "string",
957 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
Earl Lee2e463fb2025-04-17 11:22:22 -0700958 }
959 },
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700960 "required": ["title", "branch_name"]
Earl Lee2e463fb2025-04-17 11:22:22 -0700961}`),
962 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
963 var params struct {
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700964 Title string `json:"title"`
965 BranchName string `json:"branch_name"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700966 }
967 if err := json.Unmarshal(input, &params); err != nil {
968 return "", err
969 }
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700970 // It's unfortunate to not allow title changes,
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000971 // but it avoids accidentally generating multiple branches.
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700972 t := a.Title()
973 if t != "" {
974 return "", fmt.Errorf("title already set to: %s", t)
975 }
976
977 if params.BranchName == "" {
978 return "", fmt.Errorf("branch_name parameter cannot be empty")
979 }
980 if params.Title == "" {
981 return "", fmt.Errorf("title parameter cannot be empty")
982 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -0700983 if params.BranchName != cleanBranchName(params.BranchName) {
984 return "", fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
985 }
986 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000987 if branchExists(a.workingDir, branchName) {
988 return "", fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
989 }
990
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700991 a.SetTitleBranch(params.Title, branchName)
992
993 response := fmt.Sprintf("Title set to %q, branch name set to %q", params.Title, branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000994
995 if experiment.Enabled("precommit") {
996 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
997 if err != nil {
998 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
999 }
1000 if len(styleHint) > 0 {
1001 response += "\n\n" + styleHint
1002 }
1003 }
1004
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001005 return response, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001006 },
1007 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001008 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001009}
1010
1011func (a *Agent) Ready() <-chan struct{} {
1012 return a.ready
1013}
1014
1015func (a *Agent) UserMessage(ctx context.Context, msg string) {
1016 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1017 a.inbox <- msg
1018}
1019
Sean McCullough485afc62025-04-28 14:28:39 -07001020func (a *Agent) ToolResultMessage(ctx context.Context, toolCallID, msg string) {
1021 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg, ToolCallId: toolCallID})
1022 a.inbox <- msg
1023}
1024
Earl Lee2e463fb2025-04-17 11:22:22 -07001025func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1026 return a.convo.CancelToolUse(toolUseID, cause)
1027}
1028
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001029func (a *Agent) CancelTurn(cause error) {
1030 a.cancelTurnMu.Lock()
1031 defer a.cancelTurnMu.Unlock()
1032 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001033 // Force state transition to cancelled state
1034 ctx := a.config.Context
1035 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001036 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001037 }
1038}
1039
1040func (a *Agent) Loop(ctxOuter context.Context) {
1041 for {
1042 select {
1043 case <-ctxOuter.Done():
1044 return
1045 default:
1046 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001047 a.cancelTurnMu.Lock()
1048 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001049 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001050 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001051 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001052 a.cancelTurn = cancel
1053 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001054 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1055 if err != nil {
1056 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1057 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001058 cancel(nil)
1059 }
1060 }
1061}
1062
1063func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1064 if m.Timestamp.IsZero() {
1065 m.Timestamp = time.Now()
1066 }
1067
1068 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1069 if m.EndOfTurn && m.Type == AgentMessageType {
1070 turnDuration := time.Since(a.startOfTurn)
1071 m.TurnDuration = &turnDuration
1072 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1073 }
1074
Earl Lee2e463fb2025-04-17 11:22:22 -07001075 a.mu.Lock()
1076 defer a.mu.Unlock()
1077 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001078 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001079 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001080
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001081 // Notify all subscribers
1082 for _, ch := range a.subscribers {
1083 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001084 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001085}
1086
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001087func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1088 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001089 if block {
1090 select {
1091 case <-ctx.Done():
1092 return m, ctx.Err()
1093 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001094 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001095 }
1096 }
1097 for {
1098 select {
1099 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001100 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001101 default:
1102 return m, nil
1103 }
1104 }
1105}
1106
Sean McCullough885a16a2025-04-30 02:49:25 +00001107// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001108func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001109 // Reset the start of turn time
1110 a.startOfTurn = time.Now()
1111
Sean McCullough96b60dd2025-04-30 09:49:10 -07001112 // Transition to waiting for user input state
1113 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1114
Sean McCullough885a16a2025-04-30 02:49:25 +00001115 // Process initial user message
1116 initialResp, err := a.processUserMessage(ctx)
1117 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001118 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001119 return err
1120 }
1121
1122 // Handle edge case where both initialResp and err are nil
1123 if initialResp == nil {
1124 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001125 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1126
Sean McCullough9f4b8082025-04-30 17:34:07 +00001127 a.pushToOutbox(ctx, errorMessage(err))
1128 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001129 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001130
Earl Lee2e463fb2025-04-17 11:22:22 -07001131 // We do this as we go, but let's also do it at the end of the turn
1132 defer func() {
1133 if _, err := a.handleGitCommits(ctx); err != nil {
1134 // Just log the error, don't stop execution
1135 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1136 }
1137 }()
1138
Sean McCullougha1e0e492025-05-01 10:51:08 -07001139 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001140 resp := initialResp
1141 for {
1142 // Check if we are over budget
1143 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001144 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001145 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001146 }
1147
1148 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001149 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001150 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001151 break
1152 }
1153
Sean McCullough96b60dd2025-04-30 09:49:10 -07001154 // Transition to tool use requested state
1155 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1156
Sean McCullough885a16a2025-04-30 02:49:25 +00001157 // Handle tool execution
1158 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1159 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001160 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001161 }
1162
Sean McCullougha1e0e492025-05-01 10:51:08 -07001163 if toolResp == nil {
1164 return fmt.Errorf("cannot continue conversation with a nil tool response")
1165 }
1166
Sean McCullough885a16a2025-04-30 02:49:25 +00001167 // Set the response for the next iteration
1168 resp = toolResp
1169 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001170
1171 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001172}
1173
1174// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001175func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001176 // Wait for at least one message from the user
1177 msgs, err := a.GatherMessages(ctx, true)
1178 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001179 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001180 return nil, err
1181 }
1182
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001183 userMessage := llm.Message{
1184 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001185 Content: msgs,
1186 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001187
Sean McCullough96b60dd2025-04-30 09:49:10 -07001188 // Transition to sending to LLM state
1189 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1190
Sean McCullough885a16a2025-04-30 02:49:25 +00001191 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001192 resp, err := a.convo.SendMessage(userMessage)
1193 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001194 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001195 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001196 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001197 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001198
Sean McCullough96b60dd2025-04-30 09:49:10 -07001199 // Transition to processing LLM response state
1200 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1201
Sean McCullough885a16a2025-04-30 02:49:25 +00001202 return resp, nil
1203}
1204
1205// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001206func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1207 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001208 cancelled := false
1209
Sean McCullough96b60dd2025-04-30 09:49:10 -07001210 // Transition to checking for cancellation state
1211 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1212
Sean McCullough885a16a2025-04-30 02:49:25 +00001213 // Check if the operation was cancelled by the user
1214 select {
1215 case <-ctx.Done():
1216 // Don't actually run any of the tools, but rather build a response
1217 // for each tool_use message letting the LLM know that user canceled it.
1218 var err error
1219 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001220 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001221 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001222 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001223 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001224 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001225 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001226 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001227 // Transition to running tool state
1228 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1229
Sean McCullough885a16a2025-04-30 02:49:25 +00001230 // Add working directory to context for tool execution
1231 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1232
1233 // Execute the tools
1234 var err error
1235 results, err = a.convo.ToolResultContents(ctx, resp)
1236 if ctx.Err() != nil { // e.g. the user canceled the operation
1237 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001238 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001239 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001240 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001241 a.pushToOutbox(ctx, errorMessage(err))
1242 }
1243 }
1244
1245 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001246 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001247 autoqualityMessages := a.processGitChanges(ctx)
1248
1249 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001250 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001251 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001252 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001253 return false, nil
1254 }
1255
1256 // Continue the conversation with tool results and any user messages
1257 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1258}
1259
1260// processGitChanges checks for new git commits and runs autoformatters if needed
1261func (a *Agent) processGitChanges(ctx context.Context) []string {
1262 // Check for git commits after tool execution
1263 newCommits, err := a.handleGitCommits(ctx)
1264 if err != nil {
1265 // Just log the error, don't stop execution
1266 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1267 return nil
1268 }
1269
1270 // Run autoformatters if there was exactly one new commit
1271 var autoqualityMessages []string
1272 if len(newCommits) == 1 {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001273 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running autoformatters on new commit")
Sean McCullough885a16a2025-04-30 02:49:25 +00001274 formatted := a.codereview.Autoformat(ctx)
1275 if len(formatted) > 0 {
1276 msg := fmt.Sprintf(`
Earl Lee2e463fb2025-04-17 11:22:22 -07001277I ran autoformatters and they updated these files:
1278
1279%s
1280
1281Please amend your latest git commit with these changes and then continue with what you were doing.`,
Sean McCullough885a16a2025-04-30 02:49:25 +00001282 strings.Join(formatted, "\n"),
1283 )[1:]
1284 a.pushToOutbox(ctx, AgentMessage{
1285 Type: AutoMessageType,
1286 Content: msg,
1287 Timestamp: time.Now(),
1288 })
1289 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001290 }
1291 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001292
1293 return autoqualityMessages
1294}
1295
1296// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001297func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001298 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001299 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001300 msgs, err := a.GatherMessages(ctx, false)
1301 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001302 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001303 return false, nil
1304 }
1305
1306 // Inject any auto-generated messages from quality checks
1307 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001308 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001309 }
1310
1311 // Handle cancellation by appending a message about it
1312 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001313 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001314 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001315 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001316 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1317 } else if err := a.convo.OverBudget(); err != nil {
1318 // Handle budget issues by appending a message about it
1319 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 -07001320 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001321 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1322 }
1323
1324 // Combine tool results with user messages
1325 results = append(results, msgs...)
1326
1327 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001328 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001329 resp, err := a.convo.SendMessage(llm.Message{
1330 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001331 Content: results,
1332 })
1333 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001334 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001335 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1336 return true, nil // Return true to continue the conversation, but with no response
1337 }
1338
Sean McCullough96b60dd2025-04-30 09:49:10 -07001339 // Transition back to processing LLM response
1340 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1341
Sean McCullough885a16a2025-04-30 02:49:25 +00001342 if cancelled {
1343 return false, nil
1344 }
1345
1346 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001347}
1348
1349func (a *Agent) overBudget(ctx context.Context) error {
1350 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001351 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001352 m := budgetMessage(err)
1353 m.Content = m.Content + "\n\nBudget reset."
1354 a.pushToOutbox(ctx, budgetMessage(err))
1355 a.convo.ResetBudget(a.originalBudget)
1356 return err
1357 }
1358 return nil
1359}
1360
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001361func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001362 // Collect all text content
1363 var allText strings.Builder
1364 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001365 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001366 if allText.Len() > 0 {
1367 allText.WriteString("\n\n")
1368 }
1369 allText.WriteString(content.Text)
1370 }
1371 }
1372 return allText.String()
1373}
1374
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001375func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001376 a.mu.Lock()
1377 defer a.mu.Unlock()
1378 return a.convo.CumulativeUsage()
1379}
1380
Earl Lee2e463fb2025-04-17 11:22:22 -07001381// Diff returns a unified diff of changes made since the agent was instantiated.
1382func (a *Agent) Diff(commit *string) (string, error) {
1383 if a.initialCommit == "" {
1384 return "", fmt.Errorf("no initial commit reference available")
1385 }
1386
1387 // Find the repository root
1388 ctx := context.Background()
1389
1390 // If a specific commit hash is provided, show just that commit's changes
1391 if commit != nil && *commit != "" {
1392 // Validate that the commit looks like a valid git SHA
1393 if !isValidGitSHA(*commit) {
1394 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1395 }
1396
1397 // Get the diff for just this commit
1398 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1399 cmd.Dir = a.repoRoot
1400 output, err := cmd.CombinedOutput()
1401 if err != nil {
1402 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1403 }
1404 return string(output), nil
1405 }
1406
1407 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1408 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1409 cmd.Dir = a.repoRoot
1410 output, err := cmd.CombinedOutput()
1411 if err != nil {
1412 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1413 }
1414
1415 return string(output), nil
1416}
1417
1418// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1419func (a *Agent) InitialCommit() string {
1420 return a.initialCommit
1421}
1422
1423// handleGitCommits() highlights new commits to the user. When running
1424// under docker, new HEADs are pushed to a branch according to the title.
1425func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1426 if a.repoRoot == "" {
1427 return nil, nil
1428 }
1429
1430 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1431 if err != nil {
1432 return nil, err
1433 }
1434 if head == a.lastHEAD {
1435 return nil, nil // nothing to do
1436 }
1437 defer func() {
1438 a.lastHEAD = head
1439 }()
1440
1441 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1442 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1443 // to the last 100 commits.
1444 var commits []*GitCommit
1445
1446 // Get commits since the initial commit
1447 // Format: <hash>\0<subject>\0<body>\0
1448 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1449 // Limit to 100 commits to avoid overwhelming the user
1450 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1451 cmd.Dir = a.repoRoot
1452 output, err := cmd.Output()
1453 if err != nil {
1454 return nil, fmt.Errorf("failed to get git log: %w", err)
1455 }
1456
1457 // Parse git log output and filter out already seen commits
1458 parsedCommits := parseGitLog(string(output))
1459
1460 var headCommit *GitCommit
1461
1462 // Filter out commits we've already seen
1463 for _, commit := range parsedCommits {
1464 if commit.Hash == head {
1465 headCommit = &commit
1466 }
1467
1468 // Skip if we've seen this commit before. If our head has changed, always include that.
1469 if a.seenCommits[commit.Hash] && commit.Hash != head {
1470 continue
1471 }
1472
1473 // Mark this commit as seen
1474 a.seenCommits[commit.Hash] = true
1475
1476 // Add to our list of new commits
1477 commits = append(commits, &commit)
1478 }
1479
1480 if a.gitRemoteAddr != "" {
1481 if headCommit == nil {
1482 // I think this can only happen if we have a bug or if there's a race.
1483 headCommit = &GitCommit{}
1484 headCommit.Hash = head
1485 headCommit.Subject = "unknown"
1486 commits = append(commits, headCommit)
1487 }
1488
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001489 branch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
Earl Lee2e463fb2025-04-17 11:22:22 -07001490
1491 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1492 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1493 // then use push with lease to replace.
1494 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1495 cmd.Dir = a.workingDir
1496 if out, err := cmd.CombinedOutput(); err != nil {
1497 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1498 } else {
1499 headCommit.PushedBranch = branch
1500 }
1501 }
1502
1503 // If we found new commits, create a message
1504 if len(commits) > 0 {
1505 msg := AgentMessage{
1506 Type: CommitMessageType,
1507 Timestamp: time.Now(),
1508 Commits: commits,
1509 }
1510 a.pushToOutbox(ctx, msg)
1511 }
1512 return commits, nil
1513}
1514
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001515func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001516 return strings.Map(func(r rune) rune {
1517 // lowercase
1518 if r >= 'A' && r <= 'Z' {
1519 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001520 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001521 // replace spaces with dashes
1522 if r == ' ' {
1523 return '-'
1524 }
1525 // allow alphanumerics and dashes
1526 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1527 return r
1528 }
1529 return -1
1530 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001531}
1532
1533// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1534// and returns an array of GitCommit structs.
1535func parseGitLog(output string) []GitCommit {
1536 var commits []GitCommit
1537
1538 // No output means no commits
1539 if len(output) == 0 {
1540 return commits
1541 }
1542
1543 // Split by NULL byte
1544 parts := strings.Split(output, "\x00")
1545
1546 // Process in triplets (hash, subject, body)
1547 for i := 0; i < len(parts); i++ {
1548 // Skip empty parts
1549 if parts[i] == "" {
1550 continue
1551 }
1552
1553 // This should be a hash
1554 hash := strings.TrimSpace(parts[i])
1555
1556 // Make sure we have at least a subject part available
1557 if i+1 >= len(parts) {
1558 break // No more parts available
1559 }
1560
1561 // Get the subject
1562 subject := strings.TrimSpace(parts[i+1])
1563
1564 // Get the body if available
1565 body := ""
1566 if i+2 < len(parts) {
1567 body = strings.TrimSpace(parts[i+2])
1568 }
1569
1570 // Skip to the next triplet
1571 i += 2
1572
1573 commits = append(commits, GitCommit{
1574 Hash: hash,
1575 Subject: subject,
1576 Body: body,
1577 })
1578 }
1579
1580 return commits
1581}
1582
1583func repoRoot(ctx context.Context, dir string) (string, error) {
1584 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1585 stderr := new(strings.Builder)
1586 cmd.Stderr = stderr
1587 cmd.Dir = dir
1588 out, err := cmd.Output()
1589 if err != nil {
1590 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1591 }
1592 return strings.TrimSpace(string(out)), nil
1593}
1594
1595func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1596 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1597 stderr := new(strings.Builder)
1598 cmd.Stderr = stderr
1599 cmd.Dir = dir
1600 out, err := cmd.Output()
1601 if err != nil {
1602 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1603 }
1604 // TODO: validate that out is valid hex
1605 return strings.TrimSpace(string(out)), nil
1606}
1607
1608// isValidGitSHA validates if a string looks like a valid git SHA hash.
1609// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1610func isValidGitSHA(sha string) bool {
1611 // Git SHA must be a hexadecimal string with at least 4 characters
1612 if len(sha) < 4 || len(sha) > 40 {
1613 return false
1614 }
1615
1616 // Check if the string only contains hexadecimal characters
1617 for _, char := range sha {
1618 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1619 return false
1620 }
1621 }
1622
1623 return true
1624}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001625
1626// getGitOrigin returns the URL of the git remote 'origin' if it exists
1627func getGitOrigin(ctx context.Context, dir string) string {
1628 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1629 cmd.Dir = dir
1630 stderr := new(strings.Builder)
1631 cmd.Stderr = stderr
1632 out, err := cmd.Output()
1633 if err != nil {
1634 return ""
1635 }
1636 return strings.TrimSpace(string(out))
1637}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001638
1639func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1640 cmd := exec.CommandContext(ctx, "git", "stash")
1641 cmd.Dir = workingDir
1642 if out, err := cmd.CombinedOutput(); err != nil {
1643 return fmt.Errorf("git stash: %s: %v", out, err)
1644 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001645 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001646 cmd.Dir = workingDir
1647 if out, err := cmd.CombinedOutput(); err != nil {
1648 return fmt.Errorf("git fetch: %s: %w", out, err)
1649 }
1650 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1651 cmd.Dir = workingDir
1652 if out, err := cmd.CombinedOutput(); err != nil {
1653 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1654 }
1655 a.lastHEAD = revision
1656 a.initialCommit = revision
1657 return nil
1658}
1659
1660func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1661 a.mu.Lock()
1662 a.title = ""
1663 a.firstMessageIndex = len(a.history)
1664 a.convo = a.initConvo()
1665 gitReset := func() error {
1666 if a.config.InDocker && rev != "" {
1667 err := a.initGitRevision(ctx, a.workingDir, rev)
1668 if err != nil {
1669 return err
1670 }
1671 } else if !a.config.InDocker && rev != "" {
1672 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1673 }
1674 return nil
1675 }
1676 err := gitReset()
1677 a.mu.Unlock()
1678 if err != nil {
1679 a.pushToOutbox(a.config.Context, errorMessage(err))
1680 }
1681
1682 a.pushToOutbox(a.config.Context, AgentMessage{
1683 Type: AgentMessageType, Content: "Conversation restarted.",
1684 })
1685 if initialPrompt != "" {
1686 a.UserMessage(ctx, initialPrompt)
1687 }
1688 return nil
1689}
1690
1691func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1692 msg := `The user has requested a suggestion for a re-prompt.
1693
1694 Given the current conversation thus far, suggest a re-prompt that would
1695 capture the instructions and feedback so far, as well as any
1696 research or other information that would be helpful in implementing
1697 the task.
1698
1699 Reply with ONLY the reprompt text.
1700 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001701 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001702 // By doing this in a subconversation, the agent doesn't call tools (because
1703 // there aren't any), and there's not a concurrency risk with on-going other
1704 // outstanding conversations.
1705 convo := a.convo.SubConvoWithHistory()
1706 resp, err := convo.SendMessage(userMessage)
1707 if err != nil {
1708 a.pushToOutbox(ctx, errorMessage(err))
1709 return "", err
1710 }
1711 textContent := collectTextContent(resp)
1712 return textContent, nil
1713}