blob: 3da8e94f58380a666163dbfe02389477fa69f930 [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.
494 httpc := &http.Client{Timeout: 5 * time.Second}
495 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", strings.NewReader(url))
496 if err != nil {
497 slog.Debug("browser launch request connection failed", "err", err, "url", url)
498 return
499 }
500 defer resp.Body.Close()
501 if resp.StatusCode == http.StatusOK {
502 return
503 }
504 body, _ := io.ReadAll(resp.Body)
505 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
506}
507
Sean McCullough96b60dd2025-04-30 09:49:10 -0700508// CurrentState returns the current state of the agent's state machine.
509func (a *Agent) CurrentState() State {
510 return a.stateMachine.CurrentState()
511}
512
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700513func (a *Agent) IsInContainer() bool {
514 return a.config.InDocker
515}
516
517func (a *Agent) FirstMessageIndex() int {
518 a.mu.Lock()
519 defer a.mu.Unlock()
520 return a.firstMessageIndex
521}
522
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700523// SetTitleBranch sets the title and branch name of the conversation.
524func (a *Agent) SetTitleBranch(title, branchName string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700525 a.mu.Lock()
526 defer a.mu.Unlock()
527 a.title = title
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700528 a.branchName = branchName
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700529
530 // TODO: We could potentially notify listeners of a state change, but,
531 // realistically, a new message will be sent for the tool result as well.
Earl Lee2e463fb2025-04-17 11:22:22 -0700532}
533
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000534// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700535func (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 +0000536 // Track the tool call
537 a.mu.Lock()
538 a.outstandingToolCalls[id] = toolName
539 a.mu.Unlock()
540}
541
Earl Lee2e463fb2025-04-17 11:22:22 -0700542// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700543func (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 +0000544 // Remove the tool call from outstanding calls
545 a.mu.Lock()
546 delete(a.outstandingToolCalls, toolID)
547 a.mu.Unlock()
548
Earl Lee2e463fb2025-04-17 11:22:22 -0700549 m := AgentMessage{
550 Type: ToolUseMessageType,
551 Content: content.Text,
552 ToolResult: content.ToolResult,
553 ToolError: content.ToolError,
554 ToolName: toolName,
555 ToolInput: string(toolInput),
556 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700557 StartTime: content.ToolUseStartTime,
558 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700559 }
560
561 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700562 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
563 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700564 m.Elapsed = &elapsed
565 }
566
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700567 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700568 a.pushToOutbox(ctx, m)
569}
570
571// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700572func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000573 a.mu.Lock()
574 defer a.mu.Unlock()
575 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700576 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
577}
578
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700579// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700580// that need to be displayed (as well as tool calls that we send along when
581// they're done). (It would be reasonable to also mention tool calls when they're
582// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700583func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000584 // Remove the LLM call from outstanding calls
585 a.mu.Lock()
586 delete(a.outstandingLLMCalls, id)
587 a.mu.Unlock()
588
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700589 if resp == nil {
590 // LLM API call failed
591 m := AgentMessage{
592 Type: ErrorMessageType,
593 Content: "API call failed, type 'continue' to try again",
594 }
595 m.SetConvo(convo)
596 a.pushToOutbox(ctx, m)
597 return
598 }
599
Earl Lee2e463fb2025-04-17 11:22:22 -0700600 endOfTurn := false
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700601 if resp.StopReason != llm.StopReasonToolUse && convo.Parent == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700602 endOfTurn = true
603 }
604 m := AgentMessage{
605 Type: AgentMessageType,
606 Content: collectTextContent(resp),
607 EndOfTurn: endOfTurn,
608 Usage: &resp.Usage,
609 StartTime: resp.StartTime,
610 EndTime: resp.EndTime,
611 }
612
613 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700614 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700615 var toolCalls []ToolCall
616 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700617 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700618 toolCalls = append(toolCalls, ToolCall{
619 Name: part.ToolName,
620 Input: string(part.ToolInput),
621 ToolCallId: part.ID,
622 })
623 }
624 }
625 m.ToolCalls = toolCalls
626 }
627
628 // Calculate the elapsed time if both start and end times are set
629 if resp.StartTime != nil && resp.EndTime != nil {
630 elapsed := resp.EndTime.Sub(*resp.StartTime)
631 m.Elapsed = &elapsed
632 }
633
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700634 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700635 a.pushToOutbox(ctx, m)
636}
637
638// WorkingDir implements CodingAgent.
639func (a *Agent) WorkingDir() string {
640 return a.workingDir
641}
642
643// MessageCount implements CodingAgent.
644func (a *Agent) MessageCount() int {
645 a.mu.Lock()
646 defer a.mu.Unlock()
647 return len(a.history)
648}
649
650// Messages implements CodingAgent.
651func (a *Agent) Messages(start int, end int) []AgentMessage {
652 a.mu.Lock()
653 defer a.mu.Unlock()
654 return slices.Clone(a.history[start:end])
655}
656
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700657func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700658 return a.originalBudget
659}
660
661// AgentConfig contains configuration for creating a new Agent.
662type AgentConfig struct {
663 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700664 Service llm.Service
665 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700666 GitUsername string
667 GitEmail string
668 SessionID string
669 ClientGOOS string
670 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700671 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700672 UseAnthropicEdit bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000673 // Outside information
674 OutsideHostname string
675 OutsideOS string
676 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700677}
678
679// NewAgent creates a new Agent.
680// It is not usable until Init() is called.
681func NewAgent(config AgentConfig) *Agent {
682 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000683 config: config,
684 ready: make(chan struct{}),
685 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700686 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000687 startedAt: time.Now(),
688 originalBudget: config.Budget,
689 seenCommits: make(map[string]bool),
690 outsideHostname: config.OutsideHostname,
691 outsideOS: config.OutsideOS,
692 outsideWorkingDir: config.OutsideWorkingDir,
693 outstandingLLMCalls: make(map[string]struct{}),
694 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700695 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700696 }
697 return agent
698}
699
700type AgentInit struct {
701 WorkingDir string
702 NoGit bool // only for testing
703
704 InDocker bool
705 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000706 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700707 GitRemoteAddr string
708 HostAddr string
709}
710
711func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700712 if a.convo != nil {
713 return fmt.Errorf("Agent.Init: already initialized")
714 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700715 ctx := a.config.Context
716 if ini.InDocker {
717 cmd := exec.CommandContext(ctx, "git", "stash")
718 cmd.Dir = ini.WorkingDir
719 if out, err := cmd.CombinedOutput(); err != nil {
720 return fmt.Errorf("git stash: %s: %v", out, err)
721 }
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700722 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
723 cmd.Dir = ini.WorkingDir
724 if out, err := cmd.CombinedOutput(); err != nil {
725 return fmt.Errorf("git remote add: %s: %v", out, err)
726 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000727 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700728 cmd.Dir = ini.WorkingDir
729 if out, err := cmd.CombinedOutput(); err != nil {
730 return fmt.Errorf("git fetch: %s: %w", out, err)
731 }
732 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
733 cmd.Dir = ini.WorkingDir
734 if out, err := cmd.CombinedOutput(); err != nil {
735 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
736 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700737 a.lastHEAD = ini.Commit
738 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000739 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700740 a.initialCommit = ini.Commit
741 if ini.HostAddr != "" {
742 a.url = "http://" + ini.HostAddr
743 }
744 }
745 a.workingDir = ini.WorkingDir
746
747 if !ini.NoGit {
748 repoRoot, err := repoRoot(ctx, a.workingDir)
749 if err != nil {
750 return fmt.Errorf("repoRoot: %w", err)
751 }
752 a.repoRoot = repoRoot
753
754 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
755 if err != nil {
756 return fmt.Errorf("resolveRef: %w", err)
757 }
758 a.initialCommit = commitHash
759
760 codereview, err := claudetool.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit)
761 if err != nil {
762 return fmt.Errorf("Agent.Init: claudetool.NewCodeReviewer: %w", err)
763 }
764 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000765
766 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700767 }
768 a.lastHEAD = a.initialCommit
769 a.convo = a.initConvo()
770 close(a.ready)
771 return nil
772}
773
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700774//go:embed agent_system_prompt.txt
775var agentSystemPrompt string
776
Earl Lee2e463fb2025-04-17 11:22:22 -0700777// initConvo initializes the conversation.
778// It must not be called until all agent fields are initialized,
779// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700780func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700781 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700782 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700783 convo.PromptCaching = true
784 convo.Budget = a.config.Budget
785
786 var editPrompt string
787 if a.config.UseAnthropicEdit {
788 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."
789 } else {
790 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
791 }
792
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700793 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 -0700794
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000795 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
796 bashPermissionCheck := func(command string) error {
797 // Check if branch name is set
798 a.mu.Lock()
799 branchSet := a.branchName != ""
800 a.mu.Unlock()
801
802 // If branch is set, all commands are allowed
803 if branchSet {
804 return nil
805 }
806
807 // If branch is not set, check if this is a git commit command
808 willCommit, err := bashkit.WillRunGitCommit(command)
809 if err != nil {
810 // If there's an error checking, we should allow the command to proceed
811 return nil
812 }
813
814 // If it's a git commit and branch is not set, return an error
815 if willCommit {
816 return fmt.Errorf("you must use the title tool before making git commits")
817 }
818
819 return nil
820 }
821
822 // Create a custom bash tool with the permission check
823 bashTool := claudetool.NewBashTool(bashPermissionCheck)
824
Earl Lee2e463fb2025-04-17 11:22:22 -0700825 // Register all tools with the conversation
826 // When adding, removing, or modifying tools here, double-check that the termui tool display
827 // template in termui/termui.go has pretty-printing support for all tools.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700828 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000829 bashTool, claudetool.Keyword,
Earl Lee2e463fb2025-04-17 11:22:22 -0700830 claudetool.Think, a.titleTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
Sean McCullough485afc62025-04-28 14:28:39 -0700831 a.codereview.Tool(), a.multipleChoiceTool(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700832 }
833 if a.config.UseAnthropicEdit {
834 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
835 } else {
836 convo.Tools = append(convo.Tools, claudetool.Patch)
837 }
838 convo.Listener = a
839 return convo
840}
841
Sean McCullough485afc62025-04-28 14:28:39 -0700842func (a *Agent) multipleChoiceTool() *llm.Tool {
843 ret := &llm.Tool{
844 Name: "multiplechoice",
845 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.",
846 InputSchema: json.RawMessage(`{
847 "type": "object",
848 "description": "The question and a list of answers you would expect the user to choose from.",
849 "properties": {
850 "question": {
851 "type": "string",
852 "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?'"
853 },
854 "responseOptions": {
855 "type": "array",
856 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
857 "items": {
858 "type": "object",
859 "properties": {
860 "caption": {
861 "type": "string",
862 "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'"
863 },
864 "responseText": {
865 "type": "string",
866 "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'"
867 }
868 },
869 "required": ["caption", "responseText"]
870 }
871 }
872 },
873 "required": ["question", "responseOptions"]
874}`),
875 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
876 // The Run logic for "multiplchoice" tool is a no-op on the server.
877 // The UI will present a list of options for the user to select from,
878 // and that's it as far as "executing" the tool_use goes.
879 // When the user *does* select one of the presented options, that
880 // responseText gets sent as a chat message on behalf of the user.
881 return "end your turn and wait for the user to respond", nil
882 },
883 }
884 return ret
885}
886
887type MultipleChoiceOption struct {
888 Caption string `json:"caption"`
889 ResponseText string `json:"responseText"`
890}
891
892type MultipleChoiceParams struct {
893 Question string `json:"question"`
894 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
895}
896
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000897// branchExists reports whether branchName exists, either locally or in well-known remotes.
898func branchExists(dir, branchName string) bool {
899 refs := []string{
900 "refs/heads/",
901 "refs/remotes/origin/",
902 "refs/remotes/sketch-host/",
903 }
904 for _, ref := range refs {
905 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
906 cmd.Dir = dir
907 if cmd.Run() == nil { // exit code 0 means branch exists
908 return true
909 }
910 }
911 return false
912}
913
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700914func (a *Agent) titleTool() *llm.Tool {
915 title := &llm.Tool{
Earl Lee2e463fb2025-04-17 11:22:22 -0700916 Name: "title",
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700917 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 -0700918 InputSchema: json.RawMessage(`{
919 "type": "object",
920 "properties": {
921 "title": {
922 "type": "string",
Josh Bleecher Snyder250348e2025-04-30 10:31:28 -0700923 "description": "A concise title summarizing what this conversation is about, imperative tense preferred"
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700924 },
925 "branch_name": {
926 "type": "string",
927 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
Earl Lee2e463fb2025-04-17 11:22:22 -0700928 }
929 },
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700930 "required": ["title", "branch_name"]
Earl Lee2e463fb2025-04-17 11:22:22 -0700931}`),
932 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
933 var params struct {
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700934 Title string `json:"title"`
935 BranchName string `json:"branch_name"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700936 }
937 if err := json.Unmarshal(input, &params); err != nil {
938 return "", err
939 }
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700940 // It's unfortunate to not allow title changes,
941 // but it avoids having multiple branches.
942 t := a.Title()
943 if t != "" {
944 return "", fmt.Errorf("title already set to: %s", t)
945 }
946
947 if params.BranchName == "" {
948 return "", fmt.Errorf("branch_name parameter cannot be empty")
949 }
950 if params.Title == "" {
951 return "", fmt.Errorf("title parameter cannot be empty")
952 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -0700953 if params.BranchName != cleanBranchName(params.BranchName) {
954 return "", fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
955 }
956 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000957 if branchExists(a.workingDir, branchName) {
958 return "", fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
959 }
960
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700961 a.SetTitleBranch(params.Title, branchName)
962
963 response := fmt.Sprintf("Title set to %q, branch name set to %q", params.Title, branchName)
964 return response, nil
Earl Lee2e463fb2025-04-17 11:22:22 -0700965 },
966 }
967 return title
968}
969
970func (a *Agent) Ready() <-chan struct{} {
971 return a.ready
972}
973
974func (a *Agent) UserMessage(ctx context.Context, msg string) {
975 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
976 a.inbox <- msg
977}
978
Sean McCullough485afc62025-04-28 14:28:39 -0700979func (a *Agent) ToolResultMessage(ctx context.Context, toolCallID, msg string) {
980 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg, ToolCallId: toolCallID})
981 a.inbox <- msg
982}
983
Earl Lee2e463fb2025-04-17 11:22:22 -0700984func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
985 return a.convo.CancelToolUse(toolUseID, cause)
986}
987
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000988func (a *Agent) CancelTurn(cause error) {
989 a.cancelTurnMu.Lock()
990 defer a.cancelTurnMu.Unlock()
991 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -0700992 // Force state transition to cancelled state
993 ctx := a.config.Context
994 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000995 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -0700996 }
997}
998
999func (a *Agent) Loop(ctxOuter context.Context) {
1000 for {
1001 select {
1002 case <-ctxOuter.Done():
1003 return
1004 default:
1005 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001006 a.cancelTurnMu.Lock()
1007 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001008 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001009 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001010 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001011 a.cancelTurn = cancel
1012 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001013 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1014 if err != nil {
1015 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1016 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001017 cancel(nil)
1018 }
1019 }
1020}
1021
1022func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1023 if m.Timestamp.IsZero() {
1024 m.Timestamp = time.Now()
1025 }
1026
1027 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1028 if m.EndOfTurn && m.Type == AgentMessageType {
1029 turnDuration := time.Since(a.startOfTurn)
1030 m.TurnDuration = &turnDuration
1031 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1032 }
1033
Earl Lee2e463fb2025-04-17 11:22:22 -07001034 a.mu.Lock()
1035 defer a.mu.Unlock()
1036 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001037 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001038 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001039
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001040 // Notify all subscribers
1041 for _, ch := range a.subscribers {
1042 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001043 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001044}
1045
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001046func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1047 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001048 if block {
1049 select {
1050 case <-ctx.Done():
1051 return m, ctx.Err()
1052 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001053 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001054 }
1055 }
1056 for {
1057 select {
1058 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001059 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001060 default:
1061 return m, nil
1062 }
1063 }
1064}
1065
Sean McCullough885a16a2025-04-30 02:49:25 +00001066// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001067func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001068 // Reset the start of turn time
1069 a.startOfTurn = time.Now()
1070
Sean McCullough96b60dd2025-04-30 09:49:10 -07001071 // Transition to waiting for user input state
1072 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1073
Sean McCullough885a16a2025-04-30 02:49:25 +00001074 // Process initial user message
1075 initialResp, err := a.processUserMessage(ctx)
1076 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001077 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001078 return err
1079 }
1080
1081 // Handle edge case where both initialResp and err are nil
1082 if initialResp == nil {
1083 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001084 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1085
Sean McCullough9f4b8082025-04-30 17:34:07 +00001086 a.pushToOutbox(ctx, errorMessage(err))
1087 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001088 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001089
Earl Lee2e463fb2025-04-17 11:22:22 -07001090 // We do this as we go, but let's also do it at the end of the turn
1091 defer func() {
1092 if _, err := a.handleGitCommits(ctx); err != nil {
1093 // Just log the error, don't stop execution
1094 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1095 }
1096 }()
1097
Sean McCullougha1e0e492025-05-01 10:51:08 -07001098 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001099 resp := initialResp
1100 for {
1101 // Check if we are over budget
1102 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001103 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001104 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001105 }
1106
1107 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001108 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001109 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001110 break
1111 }
1112
Sean McCullough96b60dd2025-04-30 09:49:10 -07001113 // Transition to tool use requested state
1114 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1115
Sean McCullough885a16a2025-04-30 02:49:25 +00001116 // Handle tool execution
1117 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1118 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001119 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001120 }
1121
Sean McCullougha1e0e492025-05-01 10:51:08 -07001122 if toolResp == nil {
1123 return fmt.Errorf("cannot continue conversation with a nil tool response")
1124 }
1125
Sean McCullough885a16a2025-04-30 02:49:25 +00001126 // Set the response for the next iteration
1127 resp = toolResp
1128 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001129
1130 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001131}
1132
1133// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001134func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001135 // Wait for at least one message from the user
1136 msgs, err := a.GatherMessages(ctx, true)
1137 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001138 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001139 return nil, err
1140 }
1141
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001142 userMessage := llm.Message{
1143 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001144 Content: msgs,
1145 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001146
Sean McCullough96b60dd2025-04-30 09:49:10 -07001147 // Transition to sending to LLM state
1148 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1149
Sean McCullough885a16a2025-04-30 02:49:25 +00001150 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001151 resp, err := a.convo.SendMessage(userMessage)
1152 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001153 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001154 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001155 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001156 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001157
Sean McCullough96b60dd2025-04-30 09:49:10 -07001158 // Transition to processing LLM response state
1159 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1160
Sean McCullough885a16a2025-04-30 02:49:25 +00001161 return resp, nil
1162}
1163
1164// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001165func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1166 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001167 cancelled := false
1168
Sean McCullough96b60dd2025-04-30 09:49:10 -07001169 // Transition to checking for cancellation state
1170 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1171
Sean McCullough885a16a2025-04-30 02:49:25 +00001172 // Check if the operation was cancelled by the user
1173 select {
1174 case <-ctx.Done():
1175 // Don't actually run any of the tools, but rather build a response
1176 // for each tool_use message letting the LLM know that user canceled it.
1177 var err error
1178 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001179 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001180 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001181 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001182 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001183 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001184 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001185 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001186 // Transition to running tool state
1187 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1188
Sean McCullough885a16a2025-04-30 02:49:25 +00001189 // Add working directory to context for tool execution
1190 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1191
1192 // Execute the tools
1193 var err error
1194 results, err = a.convo.ToolResultContents(ctx, resp)
1195 if ctx.Err() != nil { // e.g. the user canceled the operation
1196 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001197 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001198 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001199 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001200 a.pushToOutbox(ctx, errorMessage(err))
1201 }
1202 }
1203
1204 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001205 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001206 autoqualityMessages := a.processGitChanges(ctx)
1207
1208 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001209 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001210 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001211 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001212 return false, nil
1213 }
1214
1215 // Continue the conversation with tool results and any user messages
1216 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1217}
1218
1219// processGitChanges checks for new git commits and runs autoformatters if needed
1220func (a *Agent) processGitChanges(ctx context.Context) []string {
1221 // Check for git commits after tool execution
1222 newCommits, err := a.handleGitCommits(ctx)
1223 if err != nil {
1224 // Just log the error, don't stop execution
1225 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1226 return nil
1227 }
1228
1229 // Run autoformatters if there was exactly one new commit
1230 var autoqualityMessages []string
1231 if len(newCommits) == 1 {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001232 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running autoformatters on new commit")
Sean McCullough885a16a2025-04-30 02:49:25 +00001233 formatted := a.codereview.Autoformat(ctx)
1234 if len(formatted) > 0 {
1235 msg := fmt.Sprintf(`
Earl Lee2e463fb2025-04-17 11:22:22 -07001236I ran autoformatters and they updated these files:
1237
1238%s
1239
1240Please amend your latest git commit with these changes and then continue with what you were doing.`,
Sean McCullough885a16a2025-04-30 02:49:25 +00001241 strings.Join(formatted, "\n"),
1242 )[1:]
1243 a.pushToOutbox(ctx, AgentMessage{
1244 Type: AutoMessageType,
1245 Content: msg,
1246 Timestamp: time.Now(),
1247 })
1248 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001249 }
1250 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001251
1252 return autoqualityMessages
1253}
1254
1255// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001256func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001257 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001258 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001259 msgs, err := a.GatherMessages(ctx, false)
1260 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001261 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001262 return false, nil
1263 }
1264
1265 // Inject any auto-generated messages from quality checks
1266 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001267 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001268 }
1269
1270 // Handle cancellation by appending a message about it
1271 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001272 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001273 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001274 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001275 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1276 } else if err := a.convo.OverBudget(); err != nil {
1277 // Handle budget issues by appending a message about it
1278 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 -07001279 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001280 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1281 }
1282
1283 // Combine tool results with user messages
1284 results = append(results, msgs...)
1285
1286 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001287 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001288 resp, err := a.convo.SendMessage(llm.Message{
1289 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001290 Content: results,
1291 })
1292 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001293 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001294 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1295 return true, nil // Return true to continue the conversation, but with no response
1296 }
1297
Sean McCullough96b60dd2025-04-30 09:49:10 -07001298 // Transition back to processing LLM response
1299 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1300
Sean McCullough885a16a2025-04-30 02:49:25 +00001301 if cancelled {
1302 return false, nil
1303 }
1304
1305 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001306}
1307
1308func (a *Agent) overBudget(ctx context.Context) error {
1309 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001310 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001311 m := budgetMessage(err)
1312 m.Content = m.Content + "\n\nBudget reset."
1313 a.pushToOutbox(ctx, budgetMessage(err))
1314 a.convo.ResetBudget(a.originalBudget)
1315 return err
1316 }
1317 return nil
1318}
1319
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001320func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001321 // Collect all text content
1322 var allText strings.Builder
1323 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001324 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001325 if allText.Len() > 0 {
1326 allText.WriteString("\n\n")
1327 }
1328 allText.WriteString(content.Text)
1329 }
1330 }
1331 return allText.String()
1332}
1333
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001334func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001335 a.mu.Lock()
1336 defer a.mu.Unlock()
1337 return a.convo.CumulativeUsage()
1338}
1339
Earl Lee2e463fb2025-04-17 11:22:22 -07001340// Diff returns a unified diff of changes made since the agent was instantiated.
1341func (a *Agent) Diff(commit *string) (string, error) {
1342 if a.initialCommit == "" {
1343 return "", fmt.Errorf("no initial commit reference available")
1344 }
1345
1346 // Find the repository root
1347 ctx := context.Background()
1348
1349 // If a specific commit hash is provided, show just that commit's changes
1350 if commit != nil && *commit != "" {
1351 // Validate that the commit looks like a valid git SHA
1352 if !isValidGitSHA(*commit) {
1353 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1354 }
1355
1356 // Get the diff for just this commit
1357 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1358 cmd.Dir = a.repoRoot
1359 output, err := cmd.CombinedOutput()
1360 if err != nil {
1361 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1362 }
1363 return string(output), nil
1364 }
1365
1366 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1367 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1368 cmd.Dir = a.repoRoot
1369 output, err := cmd.CombinedOutput()
1370 if err != nil {
1371 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1372 }
1373
1374 return string(output), nil
1375}
1376
1377// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1378func (a *Agent) InitialCommit() string {
1379 return a.initialCommit
1380}
1381
1382// handleGitCommits() highlights new commits to the user. When running
1383// under docker, new HEADs are pushed to a branch according to the title.
1384func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1385 if a.repoRoot == "" {
1386 return nil, nil
1387 }
1388
1389 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1390 if err != nil {
1391 return nil, err
1392 }
1393 if head == a.lastHEAD {
1394 return nil, nil // nothing to do
1395 }
1396 defer func() {
1397 a.lastHEAD = head
1398 }()
1399
1400 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1401 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1402 // to the last 100 commits.
1403 var commits []*GitCommit
1404
1405 // Get commits since the initial commit
1406 // Format: <hash>\0<subject>\0<body>\0
1407 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1408 // Limit to 100 commits to avoid overwhelming the user
1409 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1410 cmd.Dir = a.repoRoot
1411 output, err := cmd.Output()
1412 if err != nil {
1413 return nil, fmt.Errorf("failed to get git log: %w", err)
1414 }
1415
1416 // Parse git log output and filter out already seen commits
1417 parsedCommits := parseGitLog(string(output))
1418
1419 var headCommit *GitCommit
1420
1421 // Filter out commits we've already seen
1422 for _, commit := range parsedCommits {
1423 if commit.Hash == head {
1424 headCommit = &commit
1425 }
1426
1427 // Skip if we've seen this commit before. If our head has changed, always include that.
1428 if a.seenCommits[commit.Hash] && commit.Hash != head {
1429 continue
1430 }
1431
1432 // Mark this commit as seen
1433 a.seenCommits[commit.Hash] = true
1434
1435 // Add to our list of new commits
1436 commits = append(commits, &commit)
1437 }
1438
1439 if a.gitRemoteAddr != "" {
1440 if headCommit == nil {
1441 // I think this can only happen if we have a bug or if there's a race.
1442 headCommit = &GitCommit{}
1443 headCommit.Hash = head
1444 headCommit.Subject = "unknown"
1445 commits = append(commits, headCommit)
1446 }
1447
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001448 branch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
Earl Lee2e463fb2025-04-17 11:22:22 -07001449
1450 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1451 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1452 // then use push with lease to replace.
1453 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1454 cmd.Dir = a.workingDir
1455 if out, err := cmd.CombinedOutput(); err != nil {
1456 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1457 } else {
1458 headCommit.PushedBranch = branch
1459 }
1460 }
1461
1462 // If we found new commits, create a message
1463 if len(commits) > 0 {
1464 msg := AgentMessage{
1465 Type: CommitMessageType,
1466 Timestamp: time.Now(),
1467 Commits: commits,
1468 }
1469 a.pushToOutbox(ctx, msg)
1470 }
1471 return commits, nil
1472}
1473
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001474func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001475 return strings.Map(func(r rune) rune {
1476 // lowercase
1477 if r >= 'A' && r <= 'Z' {
1478 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001479 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001480 // replace spaces with dashes
1481 if r == ' ' {
1482 return '-'
1483 }
1484 // allow alphanumerics and dashes
1485 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1486 return r
1487 }
1488 return -1
1489 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001490}
1491
1492// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1493// and returns an array of GitCommit structs.
1494func parseGitLog(output string) []GitCommit {
1495 var commits []GitCommit
1496
1497 // No output means no commits
1498 if len(output) == 0 {
1499 return commits
1500 }
1501
1502 // Split by NULL byte
1503 parts := strings.Split(output, "\x00")
1504
1505 // Process in triplets (hash, subject, body)
1506 for i := 0; i < len(parts); i++ {
1507 // Skip empty parts
1508 if parts[i] == "" {
1509 continue
1510 }
1511
1512 // This should be a hash
1513 hash := strings.TrimSpace(parts[i])
1514
1515 // Make sure we have at least a subject part available
1516 if i+1 >= len(parts) {
1517 break // No more parts available
1518 }
1519
1520 // Get the subject
1521 subject := strings.TrimSpace(parts[i+1])
1522
1523 // Get the body if available
1524 body := ""
1525 if i+2 < len(parts) {
1526 body = strings.TrimSpace(parts[i+2])
1527 }
1528
1529 // Skip to the next triplet
1530 i += 2
1531
1532 commits = append(commits, GitCommit{
1533 Hash: hash,
1534 Subject: subject,
1535 Body: body,
1536 })
1537 }
1538
1539 return commits
1540}
1541
1542func repoRoot(ctx context.Context, dir string) (string, error) {
1543 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1544 stderr := new(strings.Builder)
1545 cmd.Stderr = stderr
1546 cmd.Dir = dir
1547 out, err := cmd.Output()
1548 if err != nil {
1549 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1550 }
1551 return strings.TrimSpace(string(out)), nil
1552}
1553
1554func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1555 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1556 stderr := new(strings.Builder)
1557 cmd.Stderr = stderr
1558 cmd.Dir = dir
1559 out, err := cmd.Output()
1560 if err != nil {
1561 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1562 }
1563 // TODO: validate that out is valid hex
1564 return strings.TrimSpace(string(out)), nil
1565}
1566
1567// isValidGitSHA validates if a string looks like a valid git SHA hash.
1568// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1569func isValidGitSHA(sha string) bool {
1570 // Git SHA must be a hexadecimal string with at least 4 characters
1571 if len(sha) < 4 || len(sha) > 40 {
1572 return false
1573 }
1574
1575 // Check if the string only contains hexadecimal characters
1576 for _, char := range sha {
1577 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1578 return false
1579 }
1580 }
1581
1582 return true
1583}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001584
1585// getGitOrigin returns the URL of the git remote 'origin' if it exists
1586func getGitOrigin(ctx context.Context, dir string) string {
1587 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1588 cmd.Dir = dir
1589 stderr := new(strings.Builder)
1590 cmd.Stderr = stderr
1591 out, err := cmd.Output()
1592 if err != nil {
1593 return ""
1594 }
1595 return strings.TrimSpace(string(out))
1596}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001597
1598func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1599 cmd := exec.CommandContext(ctx, "git", "stash")
1600 cmd.Dir = workingDir
1601 if out, err := cmd.CombinedOutput(); err != nil {
1602 return fmt.Errorf("git stash: %s: %v", out, err)
1603 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001604 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001605 cmd.Dir = workingDir
1606 if out, err := cmd.CombinedOutput(); err != nil {
1607 return fmt.Errorf("git fetch: %s: %w", out, err)
1608 }
1609 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1610 cmd.Dir = workingDir
1611 if out, err := cmd.CombinedOutput(); err != nil {
1612 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1613 }
1614 a.lastHEAD = revision
1615 a.initialCommit = revision
1616 return nil
1617}
1618
1619func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1620 a.mu.Lock()
1621 a.title = ""
1622 a.firstMessageIndex = len(a.history)
1623 a.convo = a.initConvo()
1624 gitReset := func() error {
1625 if a.config.InDocker && rev != "" {
1626 err := a.initGitRevision(ctx, a.workingDir, rev)
1627 if err != nil {
1628 return err
1629 }
1630 } else if !a.config.InDocker && rev != "" {
1631 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1632 }
1633 return nil
1634 }
1635 err := gitReset()
1636 a.mu.Unlock()
1637 if err != nil {
1638 a.pushToOutbox(a.config.Context, errorMessage(err))
1639 }
1640
1641 a.pushToOutbox(a.config.Context, AgentMessage{
1642 Type: AgentMessageType, Content: "Conversation restarted.",
1643 })
1644 if initialPrompt != "" {
1645 a.UserMessage(ctx, initialPrompt)
1646 }
1647 return nil
1648}
1649
1650func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1651 msg := `The user has requested a suggestion for a re-prompt.
1652
1653 Given the current conversation thus far, suggest a re-prompt that would
1654 capture the instructions and feedback so far, as well as any
1655 research or other information that would be helpful in implementing
1656 the task.
1657
1658 Reply with ONLY the reprompt text.
1659 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001660 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001661 // By doing this in a subconversation, the agent doesn't call tools (because
1662 // there aren't any), and there's not a concurrency risk with on-going other
1663 // outstanding conversations.
1664 convo := a.convo.SubConvoWithHistory()
1665 resp, err := convo.SendMessage(userMessage)
1666 if err != nil {
1667 a.pushToOutbox(ctx, errorMessage(err))
1668 return "", err
1669 }
1670 textContent := collectTextContent(resp)
1671 return textContent, nil
1672}