blob: 042d9a5eb9c13dcfaa397729b0f7ae6c066df66c [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 Snyder4f84ab72025-04-22 16:40:54 -070023 "sketch.dev/llm"
24 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070025)
26
27const (
28 userCancelMessage = "user requested agent to stop handling responses"
29)
30
Philip Zeyligerb7c58752025-05-01 10:10:17 -070031type MessageIterator interface {
32 // Next blocks until the next message is available. It may
33 // return nil if the underlying iterator context is done.
34 Next() *AgentMessage
35 Close()
36}
37
Earl Lee2e463fb2025-04-17 11:22:22 -070038type CodingAgent interface {
39 // Init initializes an agent inside a docker container.
40 Init(AgentInit) error
41
42 // Ready returns a channel closed after Init successfully called.
43 Ready() <-chan struct{}
44
45 // URL reports the HTTP URL of this agent.
46 URL() string
47
48 // UserMessage enqueues a message to the agent and returns immediately.
49 UserMessage(ctx context.Context, msg string)
50
Philip Zeyligerb7c58752025-05-01 10:10:17 -070051 // Returns an iterator that finishes when the context is done and
52 // starts with the given message index.
53 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070054
55 // Loop begins the agent loop returns only when ctx is cancelled.
56 Loop(ctx context.Context)
57
Sean McCulloughedc88dc2025-04-30 02:55:01 +000058 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070059
60 CancelToolUse(toolUseID string, cause error) error
61
62 // Returns a subset of the agent's message history.
63 Messages(start int, end int) []AgentMessage
64
65 // Returns the current number of messages in the history
66 MessageCount() int
67
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070068 TotalUsage() conversation.CumulativeUsage
69 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070070
Earl Lee2e463fb2025-04-17 11:22:22 -070071 WorkingDir() string
72
73 // Diff returns a unified diff of changes made since the agent was instantiated.
74 // If commit is non-nil, it shows the diff for just that specific commit.
75 Diff(commit *string) (string, error)
76
77 // InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
78 InitialCommit() string
79
80 // Title returns the current title of the conversation.
81 Title() string
82
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000083 // BranchName returns the git branch name for the conversation.
84 BranchName() string
85
Earl Lee2e463fb2025-04-17 11:22:22 -070086 // OS returns the operating system of the client.
87 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +000088
Philip Zeyligerc72fff52025-04-29 20:17:54 +000089 // SessionID returns the unique session identifier.
90 SessionID() string
91
Philip Zeyliger99a9a022025-04-27 15:15:25 +000092 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
93 OutstandingLLMCallCount() int
94
95 // OutstandingToolCalls returns the names of outstanding tool calls.
96 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +000097 OutsideOS() string
98 OutsideHostname() string
99 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000100 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000101 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
102 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700103
104 // RestartConversation resets the conversation history
105 RestartConversation(ctx context.Context, rev string, initialPrompt string) error
106 // SuggestReprompt suggests a re-prompt based on the current conversation.
107 SuggestReprompt(ctx context.Context) (string, error)
108 // IsInContainer returns true if the agent is running in a container
109 IsInContainer() bool
110 // FirstMessageIndex returns the index of the first message in the current conversation
111 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700112
113 CurrentStateName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700114}
115
116type CodingAgentMessageType string
117
118const (
119 UserMessageType CodingAgentMessageType = "user"
120 AgentMessageType CodingAgentMessageType = "agent"
121 ErrorMessageType CodingAgentMessageType = "error"
122 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
123 ToolUseMessageType CodingAgentMessageType = "tool"
124 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
125 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
126
127 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
128)
129
130type AgentMessage struct {
131 Type CodingAgentMessageType `json:"type"`
132 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
133 EndOfTurn bool `json:"end_of_turn"`
134
135 Content string `json:"content"`
136 ToolName string `json:"tool_name,omitempty"`
137 ToolInput string `json:"input,omitempty"`
138 ToolResult string `json:"tool_result,omitempty"`
139 ToolError bool `json:"tool_error,omitempty"`
140 ToolCallId string `json:"tool_call_id,omitempty"`
141
142 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
143 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
144
Sean McCulloughd9f13372025-04-21 15:08:49 -0700145 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
146 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
147
Earl Lee2e463fb2025-04-17 11:22:22 -0700148 // Commits is a list of git commits for a commit message
149 Commits []*GitCommit `json:"commits,omitempty"`
150
151 Timestamp time.Time `json:"timestamp"`
152 ConversationID string `json:"conversation_id"`
153 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700154 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700155
156 // Message timing information
157 StartTime *time.Time `json:"start_time,omitempty"`
158 EndTime *time.Time `json:"end_time,omitempty"`
159 Elapsed *time.Duration `json:"elapsed,omitempty"`
160
161 // Turn duration - the time taken for a complete agent turn
162 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
163
164 Idx int `json:"idx"`
165}
166
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700167// SetConvo sets m.ConversationID and m.ParentConversationID based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700168func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700169 if convo == nil {
170 m.ConversationID = ""
171 m.ParentConversationID = nil
172 return
173 }
174 m.ConversationID = convo.ID
175 if convo.Parent != nil {
176 m.ParentConversationID = &convo.Parent.ID
177 }
178}
179
Earl Lee2e463fb2025-04-17 11:22:22 -0700180// GitCommit represents a single git commit for a commit message
181type GitCommit struct {
182 Hash string `json:"hash"` // Full commit hash
183 Subject string `json:"subject"` // Commit subject line
184 Body string `json:"body"` // Full commit message body
185 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
186}
187
188// ToolCall represents a single tool call within an agent message
189type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700190 Name string `json:"name"`
191 Input string `json:"input"`
192 ToolCallId string `json:"tool_call_id"`
193 ResultMessage *AgentMessage `json:"result_message,omitempty"`
194 Args string `json:"args,omitempty"`
195 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700196}
197
198func (a *AgentMessage) Attr() slog.Attr {
199 var attrs []any = []any{
200 slog.String("type", string(a.Type)),
201 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700202 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700203 if a.EndOfTurn {
204 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
205 }
206 if a.Content != "" {
207 attrs = append(attrs, slog.String("content", a.Content))
208 }
209 if a.ToolName != "" {
210 attrs = append(attrs, slog.String("tool_name", a.ToolName))
211 }
212 if a.ToolInput != "" {
213 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
214 }
215 if a.Elapsed != nil {
216 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
217 }
218 if a.TurnDuration != nil {
219 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
220 }
221 if a.ToolResult != "" {
222 attrs = append(attrs, slog.String("tool_result", a.ToolResult))
223 }
224 if a.ToolError {
225 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
226 }
227 if len(a.ToolCalls) > 0 {
228 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
229 for i, tc := range a.ToolCalls {
230 toolCallAttrs = append(toolCallAttrs, slog.Group(
231 fmt.Sprintf("tool_call_%d", i),
232 slog.String("name", tc.Name),
233 slog.String("input", tc.Input),
234 ))
235 }
236 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
237 }
238 if a.ConversationID != "" {
239 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
240 }
241 if a.ParentConversationID != nil {
242 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
243 }
244 if a.Usage != nil && !a.Usage.IsZero() {
245 attrs = append(attrs, a.Usage.Attr())
246 }
247 // TODO: timestamp, convo ids, idx?
248 return slog.Group("agent_message", attrs...)
249}
250
251func errorMessage(err error) AgentMessage {
252 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
253 if os.Getenv(("DEBUG")) == "1" {
254 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
255 }
256
257 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
258}
259
260func budgetMessage(err error) AgentMessage {
261 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
262}
263
264// ConvoInterface defines the interface for conversation interactions
265type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700266 CumulativeUsage() conversation.CumulativeUsage
267 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700268 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700269 SendMessage(message llm.Message) (*llm.Response, error)
270 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700271 GetID() string
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700272 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error)
273 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700274 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700275 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700276}
277
278type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700279 convo ConvoInterface
280 config AgentConfig // config for this agent
281 workingDir string
282 repoRoot string // workingDir may be a subdir of repoRoot
283 url string
284 firstMessageIndex int // index of the first message in the current conversation
285 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
286 initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
287 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000288 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700289 ready chan struct{} // closed when the agent is initialized (only when under docker)
290 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700291 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700292 title string
293 branchName string
294 codereview *claudetool.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700295 // State machine to track agent state
296 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000297 // Outside information
298 outsideHostname string
299 outsideOS string
300 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000301 // URL of the git remote 'origin' if it exists
302 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700303
304 // Time when the current turn started (reset at the beginning of InnerLoop)
305 startOfTurn time.Time
306
307 // Inbox - for messages from the user to the agent.
308 // sent on by UserMessage
309 // . e.g. when user types into the chat textarea
310 // read from by GatherMessages
311 inbox chan string
312
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000313 // protects cancelTurn
314 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700315 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000316 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700317
318 // protects following
319 mu sync.Mutex
320
321 // Stores all messages for this agent
322 history []AgentMessage
323
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700324 // Iterators add themselves here when they're ready to be notified of new messages.
325 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700326
327 // Track git commits we've already seen (by hash)
328 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000329
330 // Track outstanding LLM call IDs
331 outstandingLLMCalls map[string]struct{}
332
333 // Track outstanding tool calls by ID with their names
334 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700335}
336
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700337// NewIterator implements CodingAgent.
338func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
339 a.mu.Lock()
340 defer a.mu.Unlock()
341
342 return &MessageIteratorImpl{
343 agent: a,
344 ctx: ctx,
345 nextMessageIdx: nextMessageIdx,
346 ch: make(chan *AgentMessage, 100),
347 }
348}
349
350type MessageIteratorImpl struct {
351 agent *Agent
352 ctx context.Context
353 nextMessageIdx int
354 ch chan *AgentMessage
355 subscribed bool
356}
357
358func (m *MessageIteratorImpl) Close() {
359 m.agent.mu.Lock()
360 defer m.agent.mu.Unlock()
361 // Delete ourselves from the subscribers list
362 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
363 return x == m.ch
364 })
365 close(m.ch)
366}
367
368func (m *MessageIteratorImpl) Next() *AgentMessage {
369 // We avoid subscription at creation to let ourselves catch up to "current state"
370 // before subscribing.
371 if !m.subscribed {
372 m.agent.mu.Lock()
373 if m.nextMessageIdx < len(m.agent.history) {
374 msg := &m.agent.history[m.nextMessageIdx]
375 m.nextMessageIdx++
376 m.agent.mu.Unlock()
377 return msg
378 }
379 // The next message doesn't exist yet, so let's subscribe
380 m.agent.subscribers = append(m.agent.subscribers, m.ch)
381 m.subscribed = true
382 m.agent.mu.Unlock()
383 }
384
385 for {
386 select {
387 case <-m.ctx.Done():
388 m.agent.mu.Lock()
389 // Delete ourselves from the subscribers list
390 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
391 return x == m.ch
392 })
393 m.subscribed = false
394 m.agent.mu.Unlock()
395 return nil
396 case msg, ok := <-m.ch:
397 if !ok {
398 // Close may have been called
399 return nil
400 }
401 if msg.Idx == m.nextMessageIdx {
402 m.nextMessageIdx++
403 return msg
404 }
405 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
406 panic("out of order message")
407 }
408 }
409}
410
Sean McCulloughd9d45812025-04-30 16:53:41 -0700411// Assert that Agent satisfies the CodingAgent interface.
412var _ CodingAgent = &Agent{}
413
414// StateName implements CodingAgent.
415func (a *Agent) CurrentStateName() string {
416 if a.stateMachine == nil {
417 return ""
418 }
419 return a.stateMachine.currentState.String()
420}
421
Earl Lee2e463fb2025-04-17 11:22:22 -0700422func (a *Agent) URL() string { return a.url }
423
424// Title returns the current title of the conversation.
425// If no title has been set, returns an empty string.
426func (a *Agent) Title() string {
427 a.mu.Lock()
428 defer a.mu.Unlock()
429 return a.title
430}
431
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000432// BranchName returns the git branch name for the conversation.
433func (a *Agent) BranchName() string {
434 a.mu.Lock()
435 defer a.mu.Unlock()
436 return a.branchName
437}
438
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000439// OutstandingLLMCallCount returns the number of outstanding LLM calls.
440func (a *Agent) OutstandingLLMCallCount() int {
441 a.mu.Lock()
442 defer a.mu.Unlock()
443 return len(a.outstandingLLMCalls)
444}
445
446// OutstandingToolCalls returns the names of outstanding tool calls.
447func (a *Agent) OutstandingToolCalls() []string {
448 a.mu.Lock()
449 defer a.mu.Unlock()
450
451 tools := make([]string, 0, len(a.outstandingToolCalls))
452 for _, toolName := range a.outstandingToolCalls {
453 tools = append(tools, toolName)
454 }
455 return tools
456}
457
Earl Lee2e463fb2025-04-17 11:22:22 -0700458// OS returns the operating system of the client.
459func (a *Agent) OS() string {
460 return a.config.ClientGOOS
461}
462
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000463func (a *Agent) SessionID() string {
464 return a.config.SessionID
465}
466
Philip Zeyliger18532b22025-04-23 21:11:46 +0000467// OutsideOS returns the operating system of the outside system.
468func (a *Agent) OutsideOS() string {
469 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000470}
471
Philip Zeyliger18532b22025-04-23 21:11:46 +0000472// OutsideHostname returns the hostname of the outside system.
473func (a *Agent) OutsideHostname() string {
474 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000475}
476
Philip Zeyliger18532b22025-04-23 21:11:46 +0000477// OutsideWorkingDir returns the working directory on the outside system.
478func (a *Agent) OutsideWorkingDir() string {
479 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000480}
481
482// GitOrigin returns the URL of the git remote 'origin' if it exists.
483func (a *Agent) GitOrigin() string {
484 return a.gitOrigin
485}
486
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000487func (a *Agent) OpenBrowser(url string) {
488 if !a.IsInContainer() {
489 browser.Open(url)
490 return
491 }
492 // We're in Docker, need to send a request to the Git server
493 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700494 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000495 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700496 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000497 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700498 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000499 return
500 }
501 defer resp.Body.Close()
502 if resp.StatusCode == http.StatusOK {
503 return
504 }
505 body, _ := io.ReadAll(resp.Body)
506 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
507}
508
Sean McCullough96b60dd2025-04-30 09:49:10 -0700509// CurrentState returns the current state of the agent's state machine.
510func (a *Agent) CurrentState() State {
511 return a.stateMachine.CurrentState()
512}
513
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700514func (a *Agent) IsInContainer() bool {
515 return a.config.InDocker
516}
517
518func (a *Agent) FirstMessageIndex() int {
519 a.mu.Lock()
520 defer a.mu.Unlock()
521 return a.firstMessageIndex
522}
523
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700524// SetTitleBranch sets the title and branch name of the conversation.
525func (a *Agent) SetTitleBranch(title, branchName string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700526 a.mu.Lock()
527 defer a.mu.Unlock()
528 a.title = title
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700529 a.branchName = branchName
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700530
531 // TODO: We could potentially notify listeners of a state change, but,
532 // realistically, a new message will be sent for the tool result as well.
Earl Lee2e463fb2025-04-17 11:22:22 -0700533}
534
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000535// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700536func (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 +0000537 // Track the tool call
538 a.mu.Lock()
539 a.outstandingToolCalls[id] = toolName
540 a.mu.Unlock()
541}
542
Earl Lee2e463fb2025-04-17 11:22:22 -0700543// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700544func (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 +0000545 // Remove the tool call from outstanding calls
546 a.mu.Lock()
547 delete(a.outstandingToolCalls, toolID)
548 a.mu.Unlock()
549
Earl Lee2e463fb2025-04-17 11:22:22 -0700550 m := AgentMessage{
551 Type: ToolUseMessageType,
552 Content: content.Text,
553 ToolResult: content.ToolResult,
554 ToolError: content.ToolError,
555 ToolName: toolName,
556 ToolInput: string(toolInput),
557 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700558 StartTime: content.ToolUseStartTime,
559 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700560 }
561
562 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700563 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
564 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700565 m.Elapsed = &elapsed
566 }
567
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700568 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700569 a.pushToOutbox(ctx, m)
570}
571
572// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700573func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000574 a.mu.Lock()
575 defer a.mu.Unlock()
576 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700577 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
578}
579
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700580// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700581// that need to be displayed (as well as tool calls that we send along when
582// they're done). (It would be reasonable to also mention tool calls when they're
583// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700584func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000585 // Remove the LLM call from outstanding calls
586 a.mu.Lock()
587 delete(a.outstandingLLMCalls, id)
588 a.mu.Unlock()
589
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700590 if resp == nil {
591 // LLM API call failed
592 m := AgentMessage{
593 Type: ErrorMessageType,
594 Content: "API call failed, type 'continue' to try again",
595 }
596 m.SetConvo(convo)
597 a.pushToOutbox(ctx, m)
598 return
599 }
600
Earl Lee2e463fb2025-04-17 11:22:22 -0700601 endOfTurn := false
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700602 if resp.StopReason != llm.StopReasonToolUse && convo.Parent == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700603 endOfTurn = true
604 }
605 m := AgentMessage{
606 Type: AgentMessageType,
607 Content: collectTextContent(resp),
608 EndOfTurn: endOfTurn,
609 Usage: &resp.Usage,
610 StartTime: resp.StartTime,
611 EndTime: resp.EndTime,
612 }
613
614 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700615 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700616 var toolCalls []ToolCall
617 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700618 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700619 toolCalls = append(toolCalls, ToolCall{
620 Name: part.ToolName,
621 Input: string(part.ToolInput),
622 ToolCallId: part.ID,
623 })
624 }
625 }
626 m.ToolCalls = toolCalls
627 }
628
629 // Calculate the elapsed time if both start and end times are set
630 if resp.StartTime != nil && resp.EndTime != nil {
631 elapsed := resp.EndTime.Sub(*resp.StartTime)
632 m.Elapsed = &elapsed
633 }
634
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700635 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700636 a.pushToOutbox(ctx, m)
637}
638
639// WorkingDir implements CodingAgent.
640func (a *Agent) WorkingDir() string {
641 return a.workingDir
642}
643
644// MessageCount implements CodingAgent.
645func (a *Agent) MessageCount() int {
646 a.mu.Lock()
647 defer a.mu.Unlock()
648 return len(a.history)
649}
650
651// Messages implements CodingAgent.
652func (a *Agent) Messages(start int, end int) []AgentMessage {
653 a.mu.Lock()
654 defer a.mu.Unlock()
655 return slices.Clone(a.history[start:end])
656}
657
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700658func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700659 return a.originalBudget
660}
661
662// AgentConfig contains configuration for creating a new Agent.
663type AgentConfig struct {
664 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700665 Service llm.Service
666 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700667 GitUsername string
668 GitEmail string
669 SessionID string
670 ClientGOOS string
671 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700672 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700673 UseAnthropicEdit bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000674 // Outside information
675 OutsideHostname string
676 OutsideOS string
677 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700678}
679
680// NewAgent creates a new Agent.
681// It is not usable until Init() is called.
682func NewAgent(config AgentConfig) *Agent {
683 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000684 config: config,
685 ready: make(chan struct{}),
686 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700687 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000688 startedAt: time.Now(),
689 originalBudget: config.Budget,
690 seenCommits: make(map[string]bool),
691 outsideHostname: config.OutsideHostname,
692 outsideOS: config.OutsideOS,
693 outsideWorkingDir: config.OutsideWorkingDir,
694 outstandingLLMCalls: make(map[string]struct{}),
695 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700696 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700697 }
698 return agent
699}
700
701type AgentInit struct {
702 WorkingDir string
703 NoGit bool // only for testing
704
705 InDocker bool
706 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000707 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700708 GitRemoteAddr string
709 HostAddr string
710}
711
712func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700713 if a.convo != nil {
714 return fmt.Errorf("Agent.Init: already initialized")
715 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700716 ctx := a.config.Context
717 if ini.InDocker {
718 cmd := exec.CommandContext(ctx, "git", "stash")
719 cmd.Dir = ini.WorkingDir
720 if out, err := cmd.CombinedOutput(); err != nil {
721 return fmt.Errorf("git stash: %s: %v", out, err)
722 }
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700723 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
724 cmd.Dir = ini.WorkingDir
725 if out, err := cmd.CombinedOutput(); err != nil {
726 return fmt.Errorf("git remote add: %s: %v", out, err)
727 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000728 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700729 cmd.Dir = ini.WorkingDir
730 if out, err := cmd.CombinedOutput(); err != nil {
731 return fmt.Errorf("git fetch: %s: %w", out, err)
732 }
733 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
734 cmd.Dir = ini.WorkingDir
735 if out, err := cmd.CombinedOutput(); err != nil {
736 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
737 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700738 a.lastHEAD = ini.Commit
739 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000740 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700741 a.initialCommit = ini.Commit
742 if ini.HostAddr != "" {
743 a.url = "http://" + ini.HostAddr
744 }
745 }
746 a.workingDir = ini.WorkingDir
747
748 if !ini.NoGit {
749 repoRoot, err := repoRoot(ctx, a.workingDir)
750 if err != nil {
751 return fmt.Errorf("repoRoot: %w", err)
752 }
753 a.repoRoot = repoRoot
754
755 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
756 if err != nil {
757 return fmt.Errorf("resolveRef: %w", err)
758 }
759 a.initialCommit = commitHash
760
761 codereview, err := claudetool.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit)
762 if err != nil {
763 return fmt.Errorf("Agent.Init: claudetool.NewCodeReviewer: %w", err)
764 }
765 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000766
767 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700768 }
769 a.lastHEAD = a.initialCommit
770 a.convo = a.initConvo()
771 close(a.ready)
772 return nil
773}
774
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700775//go:embed agent_system_prompt.txt
776var agentSystemPrompt string
777
Earl Lee2e463fb2025-04-17 11:22:22 -0700778// initConvo initializes the conversation.
779// It must not be called until all agent fields are initialized,
780// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700781func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700782 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700783 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700784 convo.PromptCaching = true
785 convo.Budget = a.config.Budget
786
787 var editPrompt string
788 if a.config.UseAnthropicEdit {
789 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."
790 } else {
791 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
792 }
793
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700794 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 -0700795
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000796 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
797 bashPermissionCheck := func(command string) error {
798 // Check if branch name is set
799 a.mu.Lock()
800 branchSet := a.branchName != ""
801 a.mu.Unlock()
802
803 // If branch is set, all commands are allowed
804 if branchSet {
805 return nil
806 }
807
808 // If branch is not set, check if this is a git commit command
809 willCommit, err := bashkit.WillRunGitCommit(command)
810 if err != nil {
811 // If there's an error checking, we should allow the command to proceed
812 return nil
813 }
814
815 // If it's a git commit and branch is not set, return an error
816 if willCommit {
817 return fmt.Errorf("you must use the title tool before making git commits")
818 }
819
820 return nil
821 }
822
823 // Create a custom bash tool with the permission check
824 bashTool := claudetool.NewBashTool(bashPermissionCheck)
825
Earl Lee2e463fb2025-04-17 11:22:22 -0700826 // Register all tools with the conversation
827 // When adding, removing, or modifying tools here, double-check that the termui tool display
828 // template in termui/termui.go has pretty-printing support for all tools.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700829 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000830 bashTool, claudetool.Keyword,
Earl Lee2e463fb2025-04-17 11:22:22 -0700831 claudetool.Think, a.titleTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
Sean McCullough485afc62025-04-28 14:28:39 -0700832 a.codereview.Tool(), a.multipleChoiceTool(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700833 }
834 if a.config.UseAnthropicEdit {
835 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
836 } else {
837 convo.Tools = append(convo.Tools, claudetool.Patch)
838 }
839 convo.Listener = a
840 return convo
841}
842
Sean McCullough485afc62025-04-28 14:28:39 -0700843func (a *Agent) multipleChoiceTool() *llm.Tool {
844 ret := &llm.Tool{
845 Name: "multiplechoice",
846 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.",
847 InputSchema: json.RawMessage(`{
848 "type": "object",
849 "description": "The question and a list of answers you would expect the user to choose from.",
850 "properties": {
851 "question": {
852 "type": "string",
853 "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?'"
854 },
855 "responseOptions": {
856 "type": "array",
857 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
858 "items": {
859 "type": "object",
860 "properties": {
861 "caption": {
862 "type": "string",
863 "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'"
864 },
865 "responseText": {
866 "type": "string",
867 "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'"
868 }
869 },
870 "required": ["caption", "responseText"]
871 }
872 }
873 },
874 "required": ["question", "responseOptions"]
875}`),
876 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
877 // The Run logic for "multiplchoice" tool is a no-op on the server.
878 // The UI will present a list of options for the user to select from,
879 // and that's it as far as "executing" the tool_use goes.
880 // When the user *does* select one of the presented options, that
881 // responseText gets sent as a chat message on behalf of the user.
882 return "end your turn and wait for the user to respond", nil
883 },
884 }
885 return ret
886}
887
888type MultipleChoiceOption struct {
889 Caption string `json:"caption"`
890 ResponseText string `json:"responseText"`
891}
892
893type MultipleChoiceParams struct {
894 Question string `json:"question"`
895 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
896}
897
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000898// branchExists reports whether branchName exists, either locally or in well-known remotes.
899func branchExists(dir, branchName string) bool {
900 refs := []string{
901 "refs/heads/",
902 "refs/remotes/origin/",
903 "refs/remotes/sketch-host/",
904 }
905 for _, ref := range refs {
906 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
907 cmd.Dir = dir
908 if cmd.Run() == nil { // exit code 0 means branch exists
909 return true
910 }
911 }
912 return false
913}
914
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700915func (a *Agent) titleTool() *llm.Tool {
916 title := &llm.Tool{
Earl Lee2e463fb2025-04-17 11:22:22 -0700917 Name: "title",
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700918 Description: `Sets the conversation title and creates a git branch for tracking work. MANDATORY: You must use this tool before making any git commits.`,
Earl Lee2e463fb2025-04-17 11:22:22 -0700919 InputSchema: json.RawMessage(`{
920 "type": "object",
921 "properties": {
922 "title": {
923 "type": "string",
Josh Bleecher Snyder250348e2025-04-30 10:31:28 -0700924 "description": "A concise title summarizing what this conversation is about, imperative tense preferred"
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700925 },
926 "branch_name": {
927 "type": "string",
928 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
Earl Lee2e463fb2025-04-17 11:22:22 -0700929 }
930 },
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700931 "required": ["title", "branch_name"]
Earl Lee2e463fb2025-04-17 11:22:22 -0700932}`),
933 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
934 var params struct {
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700935 Title string `json:"title"`
936 BranchName string `json:"branch_name"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700937 }
938 if err := json.Unmarshal(input, &params); err != nil {
939 return "", err
940 }
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700941 // It's unfortunate to not allow title changes,
942 // but it avoids having multiple branches.
943 t := a.Title()
944 if t != "" {
945 return "", fmt.Errorf("title already set to: %s", t)
946 }
947
948 if params.BranchName == "" {
949 return "", fmt.Errorf("branch_name parameter cannot be empty")
950 }
951 if params.Title == "" {
952 return "", fmt.Errorf("title parameter cannot be empty")
953 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -0700954 if params.BranchName != cleanBranchName(params.BranchName) {
955 return "", fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
956 }
957 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000958 if branchExists(a.workingDir, branchName) {
959 return "", fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
960 }
961
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700962 a.SetTitleBranch(params.Title, branchName)
963
964 response := fmt.Sprintf("Title set to %q, branch name set to %q", params.Title, branchName)
965 return response, nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700966 },
967 }
968 return title
969}
970
971func (a *Agent) Ready() <-chan struct{} {
972 return a.ready
973}
974
975func (a *Agent) UserMessage(ctx context.Context, msg string) {
976 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
977 a.inbox <- msg
978}
979
Sean McCullough485afc62025-04-28 14:28:39 -0700980func (a *Agent) ToolResultMessage(ctx context.Context, toolCallID, msg string) {
981 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg, ToolCallId: toolCallID})
982 a.inbox <- msg
983}
984
Earl Lee2e463fb2025-04-17 11:22:22 -0700985func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
986 return a.convo.CancelToolUse(toolUseID, cause)
987}
988
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000989func (a *Agent) CancelTurn(cause error) {
990 a.cancelTurnMu.Lock()
991 defer a.cancelTurnMu.Unlock()
992 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -0700993 // Force state transition to cancelled state
994 ctx := a.config.Context
995 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000996 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -0700997 }
998}
999
1000func (a *Agent) Loop(ctxOuter context.Context) {
1001 for {
1002 select {
1003 case <-ctxOuter.Done():
1004 return
1005 default:
1006 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001007 a.cancelTurnMu.Lock()
1008 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001009 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001010 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001011 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001012 a.cancelTurn = cancel
1013 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001014 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1015 if err != nil {
1016 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1017 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001018 cancel(nil)
1019 }
1020 }
1021}
1022
1023func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1024 if m.Timestamp.IsZero() {
1025 m.Timestamp = time.Now()
1026 }
1027
1028 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1029 if m.EndOfTurn && m.Type == AgentMessageType {
1030 turnDuration := time.Since(a.startOfTurn)
1031 m.TurnDuration = &turnDuration
1032 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1033 }
1034
Earl Lee2e463fb2025-04-17 11:22:22 -07001035 a.mu.Lock()
1036 defer a.mu.Unlock()
1037 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001038 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001039 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001040
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001041 // Notify all subscribers
1042 for _, ch := range a.subscribers {
1043 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001044 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001045}
1046
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001047func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1048 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001049 if block {
1050 select {
1051 case <-ctx.Done():
1052 return m, ctx.Err()
1053 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001054 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001055 }
1056 }
1057 for {
1058 select {
1059 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001060 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001061 default:
1062 return m, nil
1063 }
1064 }
1065}
1066
Sean McCullough885a16a2025-04-30 02:49:25 +00001067// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001068func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001069 // Reset the start of turn time
1070 a.startOfTurn = time.Now()
1071
Sean McCullough96b60dd2025-04-30 09:49:10 -07001072 // Transition to waiting for user input state
1073 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1074
Sean McCullough885a16a2025-04-30 02:49:25 +00001075 // Process initial user message
1076 initialResp, err := a.processUserMessage(ctx)
1077 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001078 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001079 return err
1080 }
1081
1082 // Handle edge case where both initialResp and err are nil
1083 if initialResp == nil {
1084 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001085 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1086
Sean McCullough9f4b8082025-04-30 17:34:07 +00001087 a.pushToOutbox(ctx, errorMessage(err))
1088 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001089 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001090
Earl Lee2e463fb2025-04-17 11:22:22 -07001091 // We do this as we go, but let's also do it at the end of the turn
1092 defer func() {
1093 if _, err := a.handleGitCommits(ctx); err != nil {
1094 // Just log the error, don't stop execution
1095 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1096 }
1097 }()
1098
Sean McCullougha1e0e492025-05-01 10:51:08 -07001099 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001100 resp := initialResp
1101 for {
1102 // Check if we are over budget
1103 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001104 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001105 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001106 }
1107
1108 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001109 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001110 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001111 break
1112 }
1113
Sean McCullough96b60dd2025-04-30 09:49:10 -07001114 // Transition to tool use requested state
1115 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1116
Sean McCullough885a16a2025-04-30 02:49:25 +00001117 // Handle tool execution
1118 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1119 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001120 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001121 }
1122
Sean McCullougha1e0e492025-05-01 10:51:08 -07001123 if toolResp == nil {
1124 return fmt.Errorf("cannot continue conversation with a nil tool response")
1125 }
1126
Sean McCullough885a16a2025-04-30 02:49:25 +00001127 // Set the response for the next iteration
1128 resp = toolResp
1129 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001130
1131 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001132}
1133
1134// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001135func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001136 // Wait for at least one message from the user
1137 msgs, err := a.GatherMessages(ctx, true)
1138 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001139 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001140 return nil, err
1141 }
1142
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001143 userMessage := llm.Message{
1144 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001145 Content: msgs,
1146 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001147
Sean McCullough96b60dd2025-04-30 09:49:10 -07001148 // Transition to sending to LLM state
1149 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1150
Sean McCullough885a16a2025-04-30 02:49:25 +00001151 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001152 resp, err := a.convo.SendMessage(userMessage)
1153 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001154 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001155 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001156 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001157 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001158
Sean McCullough96b60dd2025-04-30 09:49:10 -07001159 // Transition to processing LLM response state
1160 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1161
Sean McCullough885a16a2025-04-30 02:49:25 +00001162 return resp, nil
1163}
1164
1165// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001166func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1167 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001168 cancelled := false
1169
Sean McCullough96b60dd2025-04-30 09:49:10 -07001170 // Transition to checking for cancellation state
1171 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1172
Sean McCullough885a16a2025-04-30 02:49:25 +00001173 // Check if the operation was cancelled by the user
1174 select {
1175 case <-ctx.Done():
1176 // Don't actually run any of the tools, but rather build a response
1177 // for each tool_use message letting the LLM know that user canceled it.
1178 var err error
1179 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001180 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001181 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001182 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001183 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001184 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001185 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001186 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001187 // Transition to running tool state
1188 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1189
Sean McCullough885a16a2025-04-30 02:49:25 +00001190 // Add working directory to context for tool execution
1191 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1192
1193 // Execute the tools
1194 var err error
1195 results, err = a.convo.ToolResultContents(ctx, resp)
1196 if ctx.Err() != nil { // e.g. the user canceled the operation
1197 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001198 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001199 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001200 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001201 a.pushToOutbox(ctx, errorMessage(err))
1202 }
1203 }
1204
1205 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001206 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001207 autoqualityMessages := a.processGitChanges(ctx)
1208
1209 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001210 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001211 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001212 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001213 return false, nil
1214 }
1215
1216 // Continue the conversation with tool results and any user messages
1217 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1218}
1219
1220// processGitChanges checks for new git commits and runs autoformatters if needed
1221func (a *Agent) processGitChanges(ctx context.Context) []string {
1222 // Check for git commits after tool execution
1223 newCommits, err := a.handleGitCommits(ctx)
1224 if err != nil {
1225 // Just log the error, don't stop execution
1226 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1227 return nil
1228 }
1229
1230 // Run autoformatters if there was exactly one new commit
1231 var autoqualityMessages []string
1232 if len(newCommits) == 1 {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001233 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running autoformatters on new commit")
Sean McCullough885a16a2025-04-30 02:49:25 +00001234 formatted := a.codereview.Autoformat(ctx)
1235 if len(formatted) > 0 {
1236 msg := fmt.Sprintf(`
Earl Lee2e463fb2025-04-17 11:22:22 -07001237I ran autoformatters and they updated these files:
1238
1239%s
1240
1241Please amend your latest git commit with these changes and then continue with what you were doing.`,
Sean McCullough885a16a2025-04-30 02:49:25 +00001242 strings.Join(formatted, "\n"),
1243 )[1:]
1244 a.pushToOutbox(ctx, AgentMessage{
1245 Type: AutoMessageType,
1246 Content: msg,
1247 Timestamp: time.Now(),
1248 })
1249 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001250 }
1251 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001252
1253 return autoqualityMessages
1254}
1255
1256// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001257func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001258 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001259 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001260 msgs, err := a.GatherMessages(ctx, false)
1261 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001262 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001263 return false, nil
1264 }
1265
1266 // Inject any auto-generated messages from quality checks
1267 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001268 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001269 }
1270
1271 // Handle cancellation by appending a message about it
1272 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001273 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001274 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001275 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001276 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1277 } else if err := a.convo.OverBudget(); err != nil {
1278 // Handle budget issues by appending a message about it
1279 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 -07001280 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001281 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1282 }
1283
1284 // Combine tool results with user messages
1285 results = append(results, msgs...)
1286
1287 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001288 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001289 resp, err := a.convo.SendMessage(llm.Message{
1290 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001291 Content: results,
1292 })
1293 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001294 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001295 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1296 return true, nil // Return true to continue the conversation, but with no response
1297 }
1298
Sean McCullough96b60dd2025-04-30 09:49:10 -07001299 // Transition back to processing LLM response
1300 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1301
Sean McCullough885a16a2025-04-30 02:49:25 +00001302 if cancelled {
1303 return false, nil
1304 }
1305
1306 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001307}
1308
1309func (a *Agent) overBudget(ctx context.Context) error {
1310 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001311 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001312 m := budgetMessage(err)
1313 m.Content = m.Content + "\n\nBudget reset."
1314 a.pushToOutbox(ctx, budgetMessage(err))
1315 a.convo.ResetBudget(a.originalBudget)
1316 return err
1317 }
1318 return nil
1319}
1320
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001321func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001322 // Collect all text content
1323 var allText strings.Builder
1324 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001325 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001326 if allText.Len() > 0 {
1327 allText.WriteString("\n\n")
1328 }
1329 allText.WriteString(content.Text)
1330 }
1331 }
1332 return allText.String()
1333}
1334
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001335func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001336 a.mu.Lock()
1337 defer a.mu.Unlock()
1338 return a.convo.CumulativeUsage()
1339}
1340
Earl Lee2e463fb2025-04-17 11:22:22 -07001341// Diff returns a unified diff of changes made since the agent was instantiated.
1342func (a *Agent) Diff(commit *string) (string, error) {
1343 if a.initialCommit == "" {
1344 return "", fmt.Errorf("no initial commit reference available")
1345 }
1346
1347 // Find the repository root
1348 ctx := context.Background()
1349
1350 // If a specific commit hash is provided, show just that commit's changes
1351 if commit != nil && *commit != "" {
1352 // Validate that the commit looks like a valid git SHA
1353 if !isValidGitSHA(*commit) {
1354 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1355 }
1356
1357 // Get the diff for just this commit
1358 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1359 cmd.Dir = a.repoRoot
1360 output, err := cmd.CombinedOutput()
1361 if err != nil {
1362 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1363 }
1364 return string(output), nil
1365 }
1366
1367 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1368 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1369 cmd.Dir = a.repoRoot
1370 output, err := cmd.CombinedOutput()
1371 if err != nil {
1372 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1373 }
1374
1375 return string(output), nil
1376}
1377
1378// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1379func (a *Agent) InitialCommit() string {
1380 return a.initialCommit
1381}
1382
1383// handleGitCommits() highlights new commits to the user. When running
1384// under docker, new HEADs are pushed to a branch according to the title.
1385func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1386 if a.repoRoot == "" {
1387 return nil, nil
1388 }
1389
1390 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1391 if err != nil {
1392 return nil, err
1393 }
1394 if head == a.lastHEAD {
1395 return nil, nil // nothing to do
1396 }
1397 defer func() {
1398 a.lastHEAD = head
1399 }()
1400
1401 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1402 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1403 // to the last 100 commits.
1404 var commits []*GitCommit
1405
1406 // Get commits since the initial commit
1407 // Format: <hash>\0<subject>\0<body>\0
1408 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1409 // Limit to 100 commits to avoid overwhelming the user
1410 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1411 cmd.Dir = a.repoRoot
1412 output, err := cmd.Output()
1413 if err != nil {
1414 return nil, fmt.Errorf("failed to get git log: %w", err)
1415 }
1416
1417 // Parse git log output and filter out already seen commits
1418 parsedCommits := parseGitLog(string(output))
1419
1420 var headCommit *GitCommit
1421
1422 // Filter out commits we've already seen
1423 for _, commit := range parsedCommits {
1424 if commit.Hash == head {
1425 headCommit = &commit
1426 }
1427
1428 // Skip if we've seen this commit before. If our head has changed, always include that.
1429 if a.seenCommits[commit.Hash] && commit.Hash != head {
1430 continue
1431 }
1432
1433 // Mark this commit as seen
1434 a.seenCommits[commit.Hash] = true
1435
1436 // Add to our list of new commits
1437 commits = append(commits, &commit)
1438 }
1439
1440 if a.gitRemoteAddr != "" {
1441 if headCommit == nil {
1442 // I think this can only happen if we have a bug or if there's a race.
1443 headCommit = &GitCommit{}
1444 headCommit.Hash = head
1445 headCommit.Subject = "unknown"
1446 commits = append(commits, headCommit)
1447 }
1448
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001449 branch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
Earl Lee2e463fb2025-04-17 11:22:22 -07001450
1451 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1452 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1453 // then use push with lease to replace.
1454 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1455 cmd.Dir = a.workingDir
1456 if out, err := cmd.CombinedOutput(); err != nil {
1457 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1458 } else {
1459 headCommit.PushedBranch = branch
1460 }
1461 }
1462
1463 // If we found new commits, create a message
1464 if len(commits) > 0 {
1465 msg := AgentMessage{
1466 Type: CommitMessageType,
1467 Timestamp: time.Now(),
1468 Commits: commits,
1469 }
1470 a.pushToOutbox(ctx, msg)
1471 }
1472 return commits, nil
1473}
1474
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001475func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001476 return strings.Map(func(r rune) rune {
1477 // lowercase
1478 if r >= 'A' && r <= 'Z' {
1479 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001480 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001481 // replace spaces with dashes
1482 if r == ' ' {
1483 return '-'
1484 }
1485 // allow alphanumerics and dashes
1486 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1487 return r
1488 }
1489 return -1
1490 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001491}
1492
1493// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1494// and returns an array of GitCommit structs.
1495func parseGitLog(output string) []GitCommit {
1496 var commits []GitCommit
1497
1498 // No output means no commits
1499 if len(output) == 0 {
1500 return commits
1501 }
1502
1503 // Split by NULL byte
1504 parts := strings.Split(output, "\x00")
1505
1506 // Process in triplets (hash, subject, body)
1507 for i := 0; i < len(parts); i++ {
1508 // Skip empty parts
1509 if parts[i] == "" {
1510 continue
1511 }
1512
1513 // This should be a hash
1514 hash := strings.TrimSpace(parts[i])
1515
1516 // Make sure we have at least a subject part available
1517 if i+1 >= len(parts) {
1518 break // No more parts available
1519 }
1520
1521 // Get the subject
1522 subject := strings.TrimSpace(parts[i+1])
1523
1524 // Get the body if available
1525 body := ""
1526 if i+2 < len(parts) {
1527 body = strings.TrimSpace(parts[i+2])
1528 }
1529
1530 // Skip to the next triplet
1531 i += 2
1532
1533 commits = append(commits, GitCommit{
1534 Hash: hash,
1535 Subject: subject,
1536 Body: body,
1537 })
1538 }
1539
1540 return commits
1541}
1542
1543func repoRoot(ctx context.Context, dir string) (string, error) {
1544 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1545 stderr := new(strings.Builder)
1546 cmd.Stderr = stderr
1547 cmd.Dir = dir
1548 out, err := cmd.Output()
1549 if err != nil {
1550 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1551 }
1552 return strings.TrimSpace(string(out)), nil
1553}
1554
1555func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1556 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1557 stderr := new(strings.Builder)
1558 cmd.Stderr = stderr
1559 cmd.Dir = dir
1560 out, err := cmd.Output()
1561 if err != nil {
1562 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1563 }
1564 // TODO: validate that out is valid hex
1565 return strings.TrimSpace(string(out)), nil
1566}
1567
1568// isValidGitSHA validates if a string looks like a valid git SHA hash.
1569// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1570func isValidGitSHA(sha string) bool {
1571 // Git SHA must be a hexadecimal string with at least 4 characters
1572 if len(sha) < 4 || len(sha) > 40 {
1573 return false
1574 }
1575
1576 // Check if the string only contains hexadecimal characters
1577 for _, char := range sha {
1578 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1579 return false
1580 }
1581 }
1582
1583 return true
1584}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001585
1586// getGitOrigin returns the URL of the git remote 'origin' if it exists
1587func getGitOrigin(ctx context.Context, dir string) string {
1588 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1589 cmd.Dir = dir
1590 stderr := new(strings.Builder)
1591 cmd.Stderr = stderr
1592 out, err := cmd.Output()
1593 if err != nil {
1594 return ""
1595 }
1596 return strings.TrimSpace(string(out))
1597}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001598
1599func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1600 cmd := exec.CommandContext(ctx, "git", "stash")
1601 cmd.Dir = workingDir
1602 if out, err := cmd.CombinedOutput(); err != nil {
1603 return fmt.Errorf("git stash: %s: %v", out, err)
1604 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001605 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001606 cmd.Dir = workingDir
1607 if out, err := cmd.CombinedOutput(); err != nil {
1608 return fmt.Errorf("git fetch: %s: %w", out, err)
1609 }
1610 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1611 cmd.Dir = workingDir
1612 if out, err := cmd.CombinedOutput(); err != nil {
1613 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1614 }
1615 a.lastHEAD = revision
1616 a.initialCommit = revision
1617 return nil
1618}
1619
1620func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1621 a.mu.Lock()
1622 a.title = ""
1623 a.firstMessageIndex = len(a.history)
1624 a.convo = a.initConvo()
1625 gitReset := func() error {
1626 if a.config.InDocker && rev != "" {
1627 err := a.initGitRevision(ctx, a.workingDir, rev)
1628 if err != nil {
1629 return err
1630 }
1631 } else if !a.config.InDocker && rev != "" {
1632 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1633 }
1634 return nil
1635 }
1636 err := gitReset()
1637 a.mu.Unlock()
1638 if err != nil {
1639 a.pushToOutbox(a.config.Context, errorMessage(err))
1640 }
1641
1642 a.pushToOutbox(a.config.Context, AgentMessage{
1643 Type: AgentMessageType, Content: "Conversation restarted.",
1644 })
1645 if initialPrompt != "" {
1646 a.UserMessage(ctx, initialPrompt)
1647 }
1648 return nil
1649}
1650
1651func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1652 msg := `The user has requested a suggestion for a re-prompt.
1653
1654 Given the current conversation thus far, suggest a re-prompt that would
1655 capture the instructions and feedback so far, as well as any
1656 research or other information that would be helpful in implementing
1657 the task.
1658
1659 Reply with ONLY the reprompt text.
1660 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001661 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001662 // By doing this in a subconversation, the agent doesn't call tools (because
1663 // there aren't any), and there's not a concurrency risk with on-going other
1664 // outstanding conversations.
1665 convo := a.convo.SubConvoWithHistory()
1666 resp, err := convo.SendMessage(userMessage)
1667 if err != nil {
1668 a.pushToOutbox(ctx, errorMessage(err))
1669 return "", err
1670 }
1671 textContent := collectTextContent(resp)
1672 return textContent, nil
1673}