blob: 703c8ce8849db8ad012f30ae0101ca4910ef580e [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 {
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000939 description := `Sets the conversation title and creates a git branch for tracking work. MANDATORY: You must use this tool before making any git commits.`
940 if experiment.Enabled("precommit") {
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000941 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.`
942 }
943 preCommit := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -0700944 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000945 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -0700946 InputSchema: json.RawMessage(`{
947 "type": "object",
948 "properties": {
949 "title": {
950 "type": "string",
Josh Bleecher Snyder250348e2025-04-30 10:31:28 -0700951 "description": "A concise title summarizing what this conversation is about, imperative tense preferred"
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700952 },
953 "branch_name": {
954 "type": "string",
955 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
Earl Lee2e463fb2025-04-17 11:22:22 -0700956 }
957 },
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700958 "required": ["title", "branch_name"]
Earl Lee2e463fb2025-04-17 11:22:22 -0700959}`),
960 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
961 var params struct {
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700962 Title string `json:"title"`
963 BranchName string `json:"branch_name"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700964 }
965 if err := json.Unmarshal(input, &params); err != nil {
966 return "", err
967 }
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700968 // It's unfortunate to not allow title changes,
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000969 // but it avoids accidentally generating multiple branches.
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700970 t := a.Title()
971 if t != "" {
972 return "", fmt.Errorf("title already set to: %s", t)
973 }
974
975 if params.BranchName == "" {
976 return "", fmt.Errorf("branch_name parameter cannot be empty")
977 }
978 if params.Title == "" {
979 return "", fmt.Errorf("title parameter cannot be empty")
980 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -0700981 if params.BranchName != cleanBranchName(params.BranchName) {
982 return "", fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
983 }
984 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000985 if branchExists(a.workingDir, branchName) {
986 return "", fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
987 }
988
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700989 a.SetTitleBranch(params.Title, branchName)
990
991 response := fmt.Sprintf("Title set to %q, branch name set to %q", params.Title, branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000992
993 if experiment.Enabled("precommit") {
994 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
995 if err != nil {
996 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
997 }
998 if len(styleHint) > 0 {
999 response += "\n\n" + styleHint
1000 }
1001 }
1002
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001003 return response, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001004 },
1005 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001006 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001007}
1008
1009func (a *Agent) Ready() <-chan struct{} {
1010 return a.ready
1011}
1012
1013func (a *Agent) UserMessage(ctx context.Context, msg string) {
1014 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1015 a.inbox <- msg
1016}
1017
Sean McCullough485afc62025-04-28 14:28:39 -07001018func (a *Agent) ToolResultMessage(ctx context.Context, toolCallID, msg string) {
1019 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg, ToolCallId: toolCallID})
1020 a.inbox <- msg
1021}
1022
Earl Lee2e463fb2025-04-17 11:22:22 -07001023func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1024 return a.convo.CancelToolUse(toolUseID, cause)
1025}
1026
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001027func (a *Agent) CancelTurn(cause error) {
1028 a.cancelTurnMu.Lock()
1029 defer a.cancelTurnMu.Unlock()
1030 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001031 // Force state transition to cancelled state
1032 ctx := a.config.Context
1033 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001034 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001035 }
1036}
1037
1038func (a *Agent) Loop(ctxOuter context.Context) {
1039 for {
1040 select {
1041 case <-ctxOuter.Done():
1042 return
1043 default:
1044 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001045 a.cancelTurnMu.Lock()
1046 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001047 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001048 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001049 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001050 a.cancelTurn = cancel
1051 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001052 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1053 if err != nil {
1054 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1055 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001056 cancel(nil)
1057 }
1058 }
1059}
1060
1061func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1062 if m.Timestamp.IsZero() {
1063 m.Timestamp = time.Now()
1064 }
1065
1066 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1067 if m.EndOfTurn && m.Type == AgentMessageType {
1068 turnDuration := time.Since(a.startOfTurn)
1069 m.TurnDuration = &turnDuration
1070 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1071 }
1072
Earl Lee2e463fb2025-04-17 11:22:22 -07001073 a.mu.Lock()
1074 defer a.mu.Unlock()
1075 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001076 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001077 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001078
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001079 // Notify all subscribers
1080 for _, ch := range a.subscribers {
1081 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001082 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001083}
1084
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001085func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1086 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001087 if block {
1088 select {
1089 case <-ctx.Done():
1090 return m, ctx.Err()
1091 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001092 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001093 }
1094 }
1095 for {
1096 select {
1097 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001098 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001099 default:
1100 return m, nil
1101 }
1102 }
1103}
1104
Sean McCullough885a16a2025-04-30 02:49:25 +00001105// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001106func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001107 // Reset the start of turn time
1108 a.startOfTurn = time.Now()
1109
Sean McCullough96b60dd2025-04-30 09:49:10 -07001110 // Transition to waiting for user input state
1111 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1112
Sean McCullough885a16a2025-04-30 02:49:25 +00001113 // Process initial user message
1114 initialResp, err := a.processUserMessage(ctx)
1115 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001116 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001117 return err
1118 }
1119
1120 // Handle edge case where both initialResp and err are nil
1121 if initialResp == nil {
1122 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001123 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1124
Sean McCullough9f4b8082025-04-30 17:34:07 +00001125 a.pushToOutbox(ctx, errorMessage(err))
1126 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001127 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001128
Earl Lee2e463fb2025-04-17 11:22:22 -07001129 // We do this as we go, but let's also do it at the end of the turn
1130 defer func() {
1131 if _, err := a.handleGitCommits(ctx); err != nil {
1132 // Just log the error, don't stop execution
1133 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1134 }
1135 }()
1136
Sean McCullougha1e0e492025-05-01 10:51:08 -07001137 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001138 resp := initialResp
1139 for {
1140 // Check if we are over budget
1141 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001142 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001143 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001144 }
1145
1146 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001147 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001148 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001149 break
1150 }
1151
Sean McCullough96b60dd2025-04-30 09:49:10 -07001152 // Transition to tool use requested state
1153 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1154
Sean McCullough885a16a2025-04-30 02:49:25 +00001155 // Handle tool execution
1156 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1157 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001158 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001159 }
1160
Sean McCullougha1e0e492025-05-01 10:51:08 -07001161 if toolResp == nil {
1162 return fmt.Errorf("cannot continue conversation with a nil tool response")
1163 }
1164
Sean McCullough885a16a2025-04-30 02:49:25 +00001165 // Set the response for the next iteration
1166 resp = toolResp
1167 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001168
1169 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001170}
1171
1172// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001173func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001174 // Wait for at least one message from the user
1175 msgs, err := a.GatherMessages(ctx, true)
1176 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001177 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001178 return nil, err
1179 }
1180
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001181 userMessage := llm.Message{
1182 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001183 Content: msgs,
1184 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001185
Sean McCullough96b60dd2025-04-30 09:49:10 -07001186 // Transition to sending to LLM state
1187 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1188
Sean McCullough885a16a2025-04-30 02:49:25 +00001189 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001190 resp, err := a.convo.SendMessage(userMessage)
1191 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001192 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001193 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001194 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001195 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001196
Sean McCullough96b60dd2025-04-30 09:49:10 -07001197 // Transition to processing LLM response state
1198 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1199
Sean McCullough885a16a2025-04-30 02:49:25 +00001200 return resp, nil
1201}
1202
1203// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001204func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1205 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001206 cancelled := false
1207
Sean McCullough96b60dd2025-04-30 09:49:10 -07001208 // Transition to checking for cancellation state
1209 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1210
Sean McCullough885a16a2025-04-30 02:49:25 +00001211 // Check if the operation was cancelled by the user
1212 select {
1213 case <-ctx.Done():
1214 // Don't actually run any of the tools, but rather build a response
1215 // for each tool_use message letting the LLM know that user canceled it.
1216 var err error
1217 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001218 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001219 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001220 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001221 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001222 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001223 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001224 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001225 // Transition to running tool state
1226 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1227
Sean McCullough885a16a2025-04-30 02:49:25 +00001228 // Add working directory to context for tool execution
1229 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1230
1231 // Execute the tools
1232 var err error
1233 results, err = a.convo.ToolResultContents(ctx, resp)
1234 if ctx.Err() != nil { // e.g. the user canceled the operation
1235 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001236 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001237 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001238 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001239 a.pushToOutbox(ctx, errorMessage(err))
1240 }
1241 }
1242
1243 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001244 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001245 autoqualityMessages := a.processGitChanges(ctx)
1246
1247 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001248 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001249 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001250 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001251 return false, nil
1252 }
1253
1254 // Continue the conversation with tool results and any user messages
1255 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1256}
1257
1258// processGitChanges checks for new git commits and runs autoformatters if needed
1259func (a *Agent) processGitChanges(ctx context.Context) []string {
1260 // Check for git commits after tool execution
1261 newCommits, err := a.handleGitCommits(ctx)
1262 if err != nil {
1263 // Just log the error, don't stop execution
1264 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1265 return nil
1266 }
1267
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001268 // Run mechanical checks if there was exactly one new commit.
1269 if len(newCommits) != 1 {
1270 return nil
1271 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001272 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001273 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1274 msg := a.codereview.RunMechanicalChecks(ctx)
1275 if msg != "" {
1276 a.pushToOutbox(ctx, AgentMessage{
1277 Type: AutoMessageType,
1278 Content: msg,
1279 Timestamp: time.Now(),
1280 })
1281 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001282 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001283
1284 return autoqualityMessages
1285}
1286
1287// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001288func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001289 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001290 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001291 msgs, err := a.GatherMessages(ctx, false)
1292 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001293 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001294 return false, nil
1295 }
1296
1297 // Inject any auto-generated messages from quality checks
1298 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001299 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001300 }
1301
1302 // Handle cancellation by appending a message about it
1303 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001304 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001305 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001306 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001307 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1308 } else if err := a.convo.OverBudget(); err != nil {
1309 // Handle budget issues by appending a message about it
1310 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 -07001311 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001312 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1313 }
1314
1315 // Combine tool results with user messages
1316 results = append(results, msgs...)
1317
1318 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001319 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001320 resp, err := a.convo.SendMessage(llm.Message{
1321 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001322 Content: results,
1323 })
1324 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001325 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001326 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1327 return true, nil // Return true to continue the conversation, but with no response
1328 }
1329
Sean McCullough96b60dd2025-04-30 09:49:10 -07001330 // Transition back to processing LLM response
1331 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1332
Sean McCullough885a16a2025-04-30 02:49:25 +00001333 if cancelled {
1334 return false, nil
1335 }
1336
1337 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001338}
1339
1340func (a *Agent) overBudget(ctx context.Context) error {
1341 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001342 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001343 m := budgetMessage(err)
1344 m.Content = m.Content + "\n\nBudget reset."
1345 a.pushToOutbox(ctx, budgetMessage(err))
1346 a.convo.ResetBudget(a.originalBudget)
1347 return err
1348 }
1349 return nil
1350}
1351
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001352func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001353 // Collect all text content
1354 var allText strings.Builder
1355 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001356 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001357 if allText.Len() > 0 {
1358 allText.WriteString("\n\n")
1359 }
1360 allText.WriteString(content.Text)
1361 }
1362 }
1363 return allText.String()
1364}
1365
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001366func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001367 a.mu.Lock()
1368 defer a.mu.Unlock()
1369 return a.convo.CumulativeUsage()
1370}
1371
Earl Lee2e463fb2025-04-17 11:22:22 -07001372// Diff returns a unified diff of changes made since the agent was instantiated.
1373func (a *Agent) Diff(commit *string) (string, error) {
1374 if a.initialCommit == "" {
1375 return "", fmt.Errorf("no initial commit reference available")
1376 }
1377
1378 // Find the repository root
1379 ctx := context.Background()
1380
1381 // If a specific commit hash is provided, show just that commit's changes
1382 if commit != nil && *commit != "" {
1383 // Validate that the commit looks like a valid git SHA
1384 if !isValidGitSHA(*commit) {
1385 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1386 }
1387
1388 // Get the diff for just this commit
1389 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1390 cmd.Dir = a.repoRoot
1391 output, err := cmd.CombinedOutput()
1392 if err != nil {
1393 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1394 }
1395 return string(output), nil
1396 }
1397
1398 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1399 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1400 cmd.Dir = a.repoRoot
1401 output, err := cmd.CombinedOutput()
1402 if err != nil {
1403 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1404 }
1405
1406 return string(output), nil
1407}
1408
1409// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1410func (a *Agent) InitialCommit() string {
1411 return a.initialCommit
1412}
1413
1414// handleGitCommits() highlights new commits to the user. When running
1415// under docker, new HEADs are pushed to a branch according to the title.
1416func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1417 if a.repoRoot == "" {
1418 return nil, nil
1419 }
1420
1421 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1422 if err != nil {
1423 return nil, err
1424 }
1425 if head == a.lastHEAD {
1426 return nil, nil // nothing to do
1427 }
1428 defer func() {
1429 a.lastHEAD = head
1430 }()
1431
1432 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1433 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1434 // to the last 100 commits.
1435 var commits []*GitCommit
1436
1437 // Get commits since the initial commit
1438 // Format: <hash>\0<subject>\0<body>\0
1439 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1440 // Limit to 100 commits to avoid overwhelming the user
1441 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1442 cmd.Dir = a.repoRoot
1443 output, err := cmd.Output()
1444 if err != nil {
1445 return nil, fmt.Errorf("failed to get git log: %w", err)
1446 }
1447
1448 // Parse git log output and filter out already seen commits
1449 parsedCommits := parseGitLog(string(output))
1450
1451 var headCommit *GitCommit
1452
1453 // Filter out commits we've already seen
1454 for _, commit := range parsedCommits {
1455 if commit.Hash == head {
1456 headCommit = &commit
1457 }
1458
1459 // Skip if we've seen this commit before. If our head has changed, always include that.
1460 if a.seenCommits[commit.Hash] && commit.Hash != head {
1461 continue
1462 }
1463
1464 // Mark this commit as seen
1465 a.seenCommits[commit.Hash] = true
1466
1467 // Add to our list of new commits
1468 commits = append(commits, &commit)
1469 }
1470
1471 if a.gitRemoteAddr != "" {
1472 if headCommit == nil {
1473 // I think this can only happen if we have a bug or if there's a race.
1474 headCommit = &GitCommit{}
1475 headCommit.Hash = head
1476 headCommit.Subject = "unknown"
1477 commits = append(commits, headCommit)
1478 }
1479
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001480 branch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
Earl Lee2e463fb2025-04-17 11:22:22 -07001481
1482 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1483 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1484 // then use push with lease to replace.
1485 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1486 cmd.Dir = a.workingDir
1487 if out, err := cmd.CombinedOutput(); err != nil {
1488 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1489 } else {
1490 headCommit.PushedBranch = branch
1491 }
1492 }
1493
1494 // If we found new commits, create a message
1495 if len(commits) > 0 {
1496 msg := AgentMessage{
1497 Type: CommitMessageType,
1498 Timestamp: time.Now(),
1499 Commits: commits,
1500 }
1501 a.pushToOutbox(ctx, msg)
1502 }
1503 return commits, nil
1504}
1505
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001506func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001507 return strings.Map(func(r rune) rune {
1508 // lowercase
1509 if r >= 'A' && r <= 'Z' {
1510 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001511 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001512 // replace spaces with dashes
1513 if r == ' ' {
1514 return '-'
1515 }
1516 // allow alphanumerics and dashes
1517 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1518 return r
1519 }
1520 return -1
1521 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001522}
1523
1524// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1525// and returns an array of GitCommit structs.
1526func parseGitLog(output string) []GitCommit {
1527 var commits []GitCommit
1528
1529 // No output means no commits
1530 if len(output) == 0 {
1531 return commits
1532 }
1533
1534 // Split by NULL byte
1535 parts := strings.Split(output, "\x00")
1536
1537 // Process in triplets (hash, subject, body)
1538 for i := 0; i < len(parts); i++ {
1539 // Skip empty parts
1540 if parts[i] == "" {
1541 continue
1542 }
1543
1544 // This should be a hash
1545 hash := strings.TrimSpace(parts[i])
1546
1547 // Make sure we have at least a subject part available
1548 if i+1 >= len(parts) {
1549 break // No more parts available
1550 }
1551
1552 // Get the subject
1553 subject := strings.TrimSpace(parts[i+1])
1554
1555 // Get the body if available
1556 body := ""
1557 if i+2 < len(parts) {
1558 body = strings.TrimSpace(parts[i+2])
1559 }
1560
1561 // Skip to the next triplet
1562 i += 2
1563
1564 commits = append(commits, GitCommit{
1565 Hash: hash,
1566 Subject: subject,
1567 Body: body,
1568 })
1569 }
1570
1571 return commits
1572}
1573
1574func repoRoot(ctx context.Context, dir string) (string, error) {
1575 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1576 stderr := new(strings.Builder)
1577 cmd.Stderr = stderr
1578 cmd.Dir = dir
1579 out, err := cmd.Output()
1580 if err != nil {
1581 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1582 }
1583 return strings.TrimSpace(string(out)), nil
1584}
1585
1586func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1587 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1588 stderr := new(strings.Builder)
1589 cmd.Stderr = stderr
1590 cmd.Dir = dir
1591 out, err := cmd.Output()
1592 if err != nil {
1593 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1594 }
1595 // TODO: validate that out is valid hex
1596 return strings.TrimSpace(string(out)), nil
1597}
1598
1599// isValidGitSHA validates if a string looks like a valid git SHA hash.
1600// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1601func isValidGitSHA(sha string) bool {
1602 // Git SHA must be a hexadecimal string with at least 4 characters
1603 if len(sha) < 4 || len(sha) > 40 {
1604 return false
1605 }
1606
1607 // Check if the string only contains hexadecimal characters
1608 for _, char := range sha {
1609 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1610 return false
1611 }
1612 }
1613
1614 return true
1615}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001616
1617// getGitOrigin returns the URL of the git remote 'origin' if it exists
1618func getGitOrigin(ctx context.Context, dir string) string {
1619 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1620 cmd.Dir = dir
1621 stderr := new(strings.Builder)
1622 cmd.Stderr = stderr
1623 out, err := cmd.Output()
1624 if err != nil {
1625 return ""
1626 }
1627 return strings.TrimSpace(string(out))
1628}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001629
1630func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1631 cmd := exec.CommandContext(ctx, "git", "stash")
1632 cmd.Dir = workingDir
1633 if out, err := cmd.CombinedOutput(); err != nil {
1634 return fmt.Errorf("git stash: %s: %v", out, err)
1635 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001636 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001637 cmd.Dir = workingDir
1638 if out, err := cmd.CombinedOutput(); err != nil {
1639 return fmt.Errorf("git fetch: %s: %w", out, err)
1640 }
1641 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1642 cmd.Dir = workingDir
1643 if out, err := cmd.CombinedOutput(); err != nil {
1644 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1645 }
1646 a.lastHEAD = revision
1647 a.initialCommit = revision
1648 return nil
1649}
1650
1651func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1652 a.mu.Lock()
1653 a.title = ""
1654 a.firstMessageIndex = len(a.history)
1655 a.convo = a.initConvo()
1656 gitReset := func() error {
1657 if a.config.InDocker && rev != "" {
1658 err := a.initGitRevision(ctx, a.workingDir, rev)
1659 if err != nil {
1660 return err
1661 }
1662 } else if !a.config.InDocker && rev != "" {
1663 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1664 }
1665 return nil
1666 }
1667 err := gitReset()
1668 a.mu.Unlock()
1669 if err != nil {
1670 a.pushToOutbox(a.config.Context, errorMessage(err))
1671 }
1672
1673 a.pushToOutbox(a.config.Context, AgentMessage{
1674 Type: AgentMessageType, Content: "Conversation restarted.",
1675 })
1676 if initialPrompt != "" {
1677 a.UserMessage(ctx, initialPrompt)
1678 }
1679 return nil
1680}
1681
1682func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1683 msg := `The user has requested a suggestion for a re-prompt.
1684
1685 Given the current conversation thus far, suggest a re-prompt that would
1686 capture the instructions and feedback so far, as well as any
1687 research or other information that would be helpful in implementing
1688 the task.
1689
1690 Reply with ONLY the reprompt text.
1691 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001692 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001693 // By doing this in a subconversation, the agent doesn't call tools (because
1694 // there aren't any), and there's not a concurrency risk with on-going other
1695 // outstanding conversations.
1696 convo := a.convo.SubConvoWithHistory()
1697 resp, err := convo.SendMessage(userMessage)
1698 if err != nil {
1699 a.pushToOutbox(ctx, errorMessage(err))
1700 return "", err
1701 }
1702 textContent := collectTextContent(resp)
1703 return textContent, nil
1704}