blob: f5f40edd481491e3a6bd89c0d5cb6ea780b3ca4c [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 Snydere2518e52025-04-29 11:13:40 -070023 "sketch.dev/experiment"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070024 "sketch.dev/llm"
25 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070026)
27
28const (
29 userCancelMessage = "user requested agent to stop handling responses"
30)
31
Philip Zeyligerb7c58752025-05-01 10:10:17 -070032type MessageIterator interface {
33 // Next blocks until the next message is available. It may
34 // return nil if the underlying iterator context is done.
35 Next() *AgentMessage
36 Close()
37}
38
Earl Lee2e463fb2025-04-17 11:22:22 -070039type CodingAgent interface {
40 // Init initializes an agent inside a docker container.
41 Init(AgentInit) error
42
43 // Ready returns a channel closed after Init successfully called.
44 Ready() <-chan struct{}
45
46 // URL reports the HTTP URL of this agent.
47 URL() string
48
49 // UserMessage enqueues a message to the agent and returns immediately.
50 UserMessage(ctx context.Context, msg string)
51
Philip Zeyligerb7c58752025-05-01 10:10:17 -070052 // Returns an iterator that finishes when the context is done and
53 // starts with the given message index.
54 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070055
56 // Loop begins the agent loop returns only when ctx is cancelled.
57 Loop(ctx context.Context)
58
Sean McCulloughedc88dc2025-04-30 02:55:01 +000059 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070060
61 CancelToolUse(toolUseID string, cause error) error
62
63 // Returns a subset of the agent's message history.
64 Messages(start int, end int) []AgentMessage
65
66 // Returns the current number of messages in the history
67 MessageCount() int
68
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070069 TotalUsage() conversation.CumulativeUsage
70 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070071
Earl Lee2e463fb2025-04-17 11:22:22 -070072 WorkingDir() string
73
74 // Diff returns a unified diff of changes made since the agent was instantiated.
75 // If commit is non-nil, it shows the diff for just that specific commit.
76 Diff(commit *string) (string, error)
77
78 // InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
79 InitialCommit() string
80
81 // Title returns the current title of the conversation.
82 Title() string
83
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000084 // BranchName returns the git branch name for the conversation.
85 BranchName() string
86
Earl Lee2e463fb2025-04-17 11:22:22 -070087 // OS returns the operating system of the client.
88 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +000089
Philip Zeyligerc72fff52025-04-29 20:17:54 +000090 // SessionID returns the unique session identifier.
91 SessionID() string
92
Philip Zeyliger99a9a022025-04-27 15:15:25 +000093 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
94 OutstandingLLMCallCount() int
95
96 // OutstandingToolCalls returns the names of outstanding tool calls.
97 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +000098 OutsideOS() string
99 OutsideHostname() string
100 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000101 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000102 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
103 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700104
105 // RestartConversation resets the conversation history
106 RestartConversation(ctx context.Context, rev string, initialPrompt string) error
107 // SuggestReprompt suggests a re-prompt based on the current conversation.
108 SuggestReprompt(ctx context.Context) (string, error)
109 // IsInContainer returns true if the agent is running in a container
110 IsInContainer() bool
111 // FirstMessageIndex returns the index of the first message in the current conversation
112 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700113
114 CurrentStateName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700115}
116
117type CodingAgentMessageType string
118
119const (
120 UserMessageType CodingAgentMessageType = "user"
121 AgentMessageType CodingAgentMessageType = "agent"
122 ErrorMessageType CodingAgentMessageType = "error"
123 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
124 ToolUseMessageType CodingAgentMessageType = "tool"
125 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
126 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
127
128 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
129)
130
131type AgentMessage struct {
132 Type CodingAgentMessageType `json:"type"`
133 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
134 EndOfTurn bool `json:"end_of_turn"`
135
136 Content string `json:"content"`
137 ToolName string `json:"tool_name,omitempty"`
138 ToolInput string `json:"input,omitempty"`
139 ToolResult string `json:"tool_result,omitempty"`
140 ToolError bool `json:"tool_error,omitempty"`
141 ToolCallId string `json:"tool_call_id,omitempty"`
142
143 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
144 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
145
Sean McCulloughd9f13372025-04-21 15:08:49 -0700146 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
147 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
148
Earl Lee2e463fb2025-04-17 11:22:22 -0700149 // Commits is a list of git commits for a commit message
150 Commits []*GitCommit `json:"commits,omitempty"`
151
152 Timestamp time.Time `json:"timestamp"`
153 ConversationID string `json:"conversation_id"`
154 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700155 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700156
157 // Message timing information
158 StartTime *time.Time `json:"start_time,omitempty"`
159 EndTime *time.Time `json:"end_time,omitempty"`
160 Elapsed *time.Duration `json:"elapsed,omitempty"`
161
162 // Turn duration - the time taken for a complete agent turn
163 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
164
165 Idx int `json:"idx"`
166}
167
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700168// SetConvo sets m.ConversationID and m.ParentConversationID based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700169func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700170 if convo == nil {
171 m.ConversationID = ""
172 m.ParentConversationID = nil
173 return
174 }
175 m.ConversationID = convo.ID
176 if convo.Parent != nil {
177 m.ParentConversationID = &convo.Parent.ID
178 }
179}
180
Earl Lee2e463fb2025-04-17 11:22:22 -0700181// GitCommit represents a single git commit for a commit message
182type GitCommit struct {
183 Hash string `json:"hash"` // Full commit hash
184 Subject string `json:"subject"` // Commit subject line
185 Body string `json:"body"` // Full commit message body
186 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
187}
188
189// ToolCall represents a single tool call within an agent message
190type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700191 Name string `json:"name"`
192 Input string `json:"input"`
193 ToolCallId string `json:"tool_call_id"`
194 ResultMessage *AgentMessage `json:"result_message,omitempty"`
195 Args string `json:"args,omitempty"`
196 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700197}
198
199func (a *AgentMessage) Attr() slog.Attr {
200 var attrs []any = []any{
201 slog.String("type", string(a.Type)),
202 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700203 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700204 if a.EndOfTurn {
205 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
206 }
207 if a.Content != "" {
208 attrs = append(attrs, slog.String("content", a.Content))
209 }
210 if a.ToolName != "" {
211 attrs = append(attrs, slog.String("tool_name", a.ToolName))
212 }
213 if a.ToolInput != "" {
214 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
215 }
216 if a.Elapsed != nil {
217 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
218 }
219 if a.TurnDuration != nil {
220 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
221 }
222 if a.ToolResult != "" {
223 attrs = append(attrs, slog.String("tool_result", a.ToolResult))
224 }
225 if a.ToolError {
226 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
227 }
228 if len(a.ToolCalls) > 0 {
229 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
230 for i, tc := range a.ToolCalls {
231 toolCallAttrs = append(toolCallAttrs, slog.Group(
232 fmt.Sprintf("tool_call_%d", i),
233 slog.String("name", tc.Name),
234 slog.String("input", tc.Input),
235 ))
236 }
237 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
238 }
239 if a.ConversationID != "" {
240 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
241 }
242 if a.ParentConversationID != nil {
243 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
244 }
245 if a.Usage != nil && !a.Usage.IsZero() {
246 attrs = append(attrs, a.Usage.Attr())
247 }
248 // TODO: timestamp, convo ids, idx?
249 return slog.Group("agent_message", attrs...)
250}
251
252func errorMessage(err error) AgentMessage {
253 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
254 if os.Getenv(("DEBUG")) == "1" {
255 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
256 }
257
258 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
259}
260
261func budgetMessage(err error) AgentMessage {
262 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
263}
264
265// ConvoInterface defines the interface for conversation interactions
266type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700267 CumulativeUsage() conversation.CumulativeUsage
268 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700269 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700270 SendMessage(message llm.Message) (*llm.Response, error)
271 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700272 GetID() string
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700273 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error)
274 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700275 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700276 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700277}
278
279type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700280 convo ConvoInterface
281 config AgentConfig // config for this agent
282 workingDir string
283 repoRoot string // workingDir may be a subdir of repoRoot
284 url string
285 firstMessageIndex int // index of the first message in the current conversation
286 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
287 initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
288 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000289 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700290 ready chan struct{} // closed when the agent is initialized (only when under docker)
291 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700292 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700293 title string
294 branchName string
295 codereview *claudetool.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700296 // State machine to track agent state
297 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000298 // Outside information
299 outsideHostname string
300 outsideOS string
301 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000302 // URL of the git remote 'origin' if it exists
303 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700304
305 // Time when the current turn started (reset at the beginning of InnerLoop)
306 startOfTurn time.Time
307
308 // Inbox - for messages from the user to the agent.
309 // sent on by UserMessage
310 // . e.g. when user types into the chat textarea
311 // read from by GatherMessages
312 inbox chan string
313
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000314 // protects cancelTurn
315 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700316 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000317 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700318
319 // protects following
320 mu sync.Mutex
321
322 // Stores all messages for this agent
323 history []AgentMessage
324
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700325 // Iterators add themselves here when they're ready to be notified of new messages.
326 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700327
328 // Track git commits we've already seen (by hash)
329 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000330
331 // Track outstanding LLM call IDs
332 outstandingLLMCalls map[string]struct{}
333
334 // Track outstanding tool calls by ID with their names
335 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700336}
337
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700338// NewIterator implements CodingAgent.
339func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
340 a.mu.Lock()
341 defer a.mu.Unlock()
342
343 return &MessageIteratorImpl{
344 agent: a,
345 ctx: ctx,
346 nextMessageIdx: nextMessageIdx,
347 ch: make(chan *AgentMessage, 100),
348 }
349}
350
351type MessageIteratorImpl struct {
352 agent *Agent
353 ctx context.Context
354 nextMessageIdx int
355 ch chan *AgentMessage
356 subscribed bool
357}
358
359func (m *MessageIteratorImpl) Close() {
360 m.agent.mu.Lock()
361 defer m.agent.mu.Unlock()
362 // Delete ourselves from the subscribers list
363 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
364 return x == m.ch
365 })
366 close(m.ch)
367}
368
369func (m *MessageIteratorImpl) Next() *AgentMessage {
370 // We avoid subscription at creation to let ourselves catch up to "current state"
371 // before subscribing.
372 if !m.subscribed {
373 m.agent.mu.Lock()
374 if m.nextMessageIdx < len(m.agent.history) {
375 msg := &m.agent.history[m.nextMessageIdx]
376 m.nextMessageIdx++
377 m.agent.mu.Unlock()
378 return msg
379 }
380 // The next message doesn't exist yet, so let's subscribe
381 m.agent.subscribers = append(m.agent.subscribers, m.ch)
382 m.subscribed = true
383 m.agent.mu.Unlock()
384 }
385
386 for {
387 select {
388 case <-m.ctx.Done():
389 m.agent.mu.Lock()
390 // Delete ourselves from the subscribers list
391 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
392 return x == m.ch
393 })
394 m.subscribed = false
395 m.agent.mu.Unlock()
396 return nil
397 case msg, ok := <-m.ch:
398 if !ok {
399 // Close may have been called
400 return nil
401 }
402 if msg.Idx == m.nextMessageIdx {
403 m.nextMessageIdx++
404 return msg
405 }
406 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
407 panic("out of order message")
408 }
409 }
410}
411
Sean McCulloughd9d45812025-04-30 16:53:41 -0700412// Assert that Agent satisfies the CodingAgent interface.
413var _ CodingAgent = &Agent{}
414
415// StateName implements CodingAgent.
416func (a *Agent) CurrentStateName() string {
417 if a.stateMachine == nil {
418 return ""
419 }
420 return a.stateMachine.currentState.String()
421}
422
Earl Lee2e463fb2025-04-17 11:22:22 -0700423func (a *Agent) URL() string { return a.url }
424
425// Title returns the current title of the conversation.
426// If no title has been set, returns an empty string.
427func (a *Agent) Title() string {
428 a.mu.Lock()
429 defer a.mu.Unlock()
430 return a.title
431}
432
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000433// BranchName returns the git branch name for the conversation.
434func (a *Agent) BranchName() string {
435 a.mu.Lock()
436 defer a.mu.Unlock()
437 return a.branchName
438}
439
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000440// OutstandingLLMCallCount returns the number of outstanding LLM calls.
441func (a *Agent) OutstandingLLMCallCount() int {
442 a.mu.Lock()
443 defer a.mu.Unlock()
444 return len(a.outstandingLLMCalls)
445}
446
447// OutstandingToolCalls returns the names of outstanding tool calls.
448func (a *Agent) OutstandingToolCalls() []string {
449 a.mu.Lock()
450 defer a.mu.Unlock()
451
452 tools := make([]string, 0, len(a.outstandingToolCalls))
453 for _, toolName := range a.outstandingToolCalls {
454 tools = append(tools, toolName)
455 }
456 return tools
457}
458
Earl Lee2e463fb2025-04-17 11:22:22 -0700459// OS returns the operating system of the client.
460func (a *Agent) OS() string {
461 return a.config.ClientGOOS
462}
463
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000464func (a *Agent) SessionID() string {
465 return a.config.SessionID
466}
467
Philip Zeyliger18532b22025-04-23 21:11:46 +0000468// OutsideOS returns the operating system of the outside system.
469func (a *Agent) OutsideOS() string {
470 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000471}
472
Philip Zeyliger18532b22025-04-23 21:11:46 +0000473// OutsideHostname returns the hostname of the outside system.
474func (a *Agent) OutsideHostname() string {
475 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000476}
477
Philip Zeyliger18532b22025-04-23 21:11:46 +0000478// OutsideWorkingDir returns the working directory on the outside system.
479func (a *Agent) OutsideWorkingDir() string {
480 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000481}
482
483// GitOrigin returns the URL of the git remote 'origin' if it exists.
484func (a *Agent) GitOrigin() string {
485 return a.gitOrigin
486}
487
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000488func (a *Agent) OpenBrowser(url string) {
489 if !a.IsInContainer() {
490 browser.Open(url)
491 return
492 }
493 // We're in Docker, need to send a request to the Git server
494 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700495 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000496 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700497 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000498 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700499 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000500 return
501 }
502 defer resp.Body.Close()
503 if resp.StatusCode == http.StatusOK {
504 return
505 }
506 body, _ := io.ReadAll(resp.Body)
507 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
508}
509
Sean McCullough96b60dd2025-04-30 09:49:10 -0700510// CurrentState returns the current state of the agent's state machine.
511func (a *Agent) CurrentState() State {
512 return a.stateMachine.CurrentState()
513}
514
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700515func (a *Agent) IsInContainer() bool {
516 return a.config.InDocker
517}
518
519func (a *Agent) FirstMessageIndex() int {
520 a.mu.Lock()
521 defer a.mu.Unlock()
522 return a.firstMessageIndex
523}
524
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700525// SetTitleBranch sets the title and branch name of the conversation.
526func (a *Agent) SetTitleBranch(title, branchName string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700527 a.mu.Lock()
528 defer a.mu.Unlock()
529 a.title = title
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700530 a.branchName = branchName
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700531
532 // TODO: We could potentially notify listeners of a state change, but,
533 // realistically, a new message will be sent for the tool result as well.
Earl Lee2e463fb2025-04-17 11:22:22 -0700534}
535
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000536// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700537func (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 +0000538 // Track the tool call
539 a.mu.Lock()
540 a.outstandingToolCalls[id] = toolName
541 a.mu.Unlock()
542}
543
Earl Lee2e463fb2025-04-17 11:22:22 -0700544// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700545func (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 +0000546 // Remove the tool call from outstanding calls
547 a.mu.Lock()
548 delete(a.outstandingToolCalls, toolID)
549 a.mu.Unlock()
550
Earl Lee2e463fb2025-04-17 11:22:22 -0700551 m := AgentMessage{
552 Type: ToolUseMessageType,
553 Content: content.Text,
554 ToolResult: content.ToolResult,
555 ToolError: content.ToolError,
556 ToolName: toolName,
557 ToolInput: string(toolInput),
558 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700559 StartTime: content.ToolUseStartTime,
560 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700561 }
562
563 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700564 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
565 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700566 m.Elapsed = &elapsed
567 }
568
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700569 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700570 a.pushToOutbox(ctx, m)
571}
572
573// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700574func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000575 a.mu.Lock()
576 defer a.mu.Unlock()
577 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700578 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
579}
580
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700581// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700582// that need to be displayed (as well as tool calls that we send along when
583// they're done). (It would be reasonable to also mention tool calls when they're
584// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700585func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000586 // Remove the LLM call from outstanding calls
587 a.mu.Lock()
588 delete(a.outstandingLLMCalls, id)
589 a.mu.Unlock()
590
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700591 if resp == nil {
592 // LLM API call failed
593 m := AgentMessage{
594 Type: ErrorMessageType,
595 Content: "API call failed, type 'continue' to try again",
596 }
597 m.SetConvo(convo)
598 a.pushToOutbox(ctx, m)
599 return
600 }
601
Earl Lee2e463fb2025-04-17 11:22:22 -0700602 endOfTurn := false
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700603 if resp.StopReason != llm.StopReasonToolUse && convo.Parent == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -0700604 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000605 } else if resp.StopReason == llm.StopReasonToolUse {
606 // Check if any of the tool calls are for tools that should end the turn
607 for _, part := range resp.Content {
608 if part.Type == llm.ContentTypeToolUse {
609 // Find the tool by name
610 for _, tool := range convo.Tools {
611 if tool.Name == part.ToolName && tool.EndsTurn {
612 endOfTurn = true
613 break
614 }
615 }
616 if endOfTurn {
617 break
618 }
619 }
620 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700621 }
622 m := AgentMessage{
623 Type: AgentMessageType,
624 Content: collectTextContent(resp),
625 EndOfTurn: endOfTurn,
626 Usage: &resp.Usage,
627 StartTime: resp.StartTime,
628 EndTime: resp.EndTime,
629 }
630
631 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700632 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700633 var toolCalls []ToolCall
634 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700635 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700636 toolCalls = append(toolCalls, ToolCall{
637 Name: part.ToolName,
638 Input: string(part.ToolInput),
639 ToolCallId: part.ID,
640 })
641 }
642 }
643 m.ToolCalls = toolCalls
644 }
645
646 // Calculate the elapsed time if both start and end times are set
647 if resp.StartTime != nil && resp.EndTime != nil {
648 elapsed := resp.EndTime.Sub(*resp.StartTime)
649 m.Elapsed = &elapsed
650 }
651
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700652 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700653 a.pushToOutbox(ctx, m)
654}
655
656// WorkingDir implements CodingAgent.
657func (a *Agent) WorkingDir() string {
658 return a.workingDir
659}
660
661// MessageCount implements CodingAgent.
662func (a *Agent) MessageCount() int {
663 a.mu.Lock()
664 defer a.mu.Unlock()
665 return len(a.history)
666}
667
668// Messages implements CodingAgent.
669func (a *Agent) Messages(start int, end int) []AgentMessage {
670 a.mu.Lock()
671 defer a.mu.Unlock()
672 return slices.Clone(a.history[start:end])
673}
674
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700675func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700676 return a.originalBudget
677}
678
679// AgentConfig contains configuration for creating a new Agent.
680type AgentConfig struct {
681 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700682 Service llm.Service
683 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700684 GitUsername string
685 GitEmail string
686 SessionID string
687 ClientGOOS string
688 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700689 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700690 UseAnthropicEdit bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000691 // Outside information
692 OutsideHostname string
693 OutsideOS string
694 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700695}
696
697// NewAgent creates a new Agent.
698// It is not usable until Init() is called.
699func NewAgent(config AgentConfig) *Agent {
700 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000701 config: config,
702 ready: make(chan struct{}),
703 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700704 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000705 startedAt: time.Now(),
706 originalBudget: config.Budget,
707 seenCommits: make(map[string]bool),
708 outsideHostname: config.OutsideHostname,
709 outsideOS: config.OutsideOS,
710 outsideWorkingDir: config.OutsideWorkingDir,
711 outstandingLLMCalls: make(map[string]struct{}),
712 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700713 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700714 }
715 return agent
716}
717
718type AgentInit struct {
719 WorkingDir string
720 NoGit bool // only for testing
721
722 InDocker bool
723 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000724 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700725 GitRemoteAddr string
726 HostAddr string
727}
728
729func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700730 if a.convo != nil {
731 return fmt.Errorf("Agent.Init: already initialized")
732 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700733 ctx := a.config.Context
734 if ini.InDocker {
735 cmd := exec.CommandContext(ctx, "git", "stash")
736 cmd.Dir = ini.WorkingDir
737 if out, err := cmd.CombinedOutput(); err != nil {
738 return fmt.Errorf("git stash: %s: %v", out, err)
739 }
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700740 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
741 cmd.Dir = ini.WorkingDir
742 if out, err := cmd.CombinedOutput(); err != nil {
743 return fmt.Errorf("git remote add: %s: %v", out, err)
744 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000745 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700746 cmd.Dir = ini.WorkingDir
747 if out, err := cmd.CombinedOutput(); err != nil {
748 return fmt.Errorf("git fetch: %s: %w", out, err)
749 }
750 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
751 cmd.Dir = ini.WorkingDir
752 if out, err := cmd.CombinedOutput(); err != nil {
753 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
754 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700755 a.lastHEAD = ini.Commit
756 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000757 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700758 a.initialCommit = ini.Commit
759 if ini.HostAddr != "" {
760 a.url = "http://" + ini.HostAddr
761 }
762 }
763 a.workingDir = ini.WorkingDir
764
765 if !ini.NoGit {
766 repoRoot, err := repoRoot(ctx, a.workingDir)
767 if err != nil {
768 return fmt.Errorf("repoRoot: %w", err)
769 }
770 a.repoRoot = repoRoot
771
772 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
773 if err != nil {
774 return fmt.Errorf("resolveRef: %w", err)
775 }
776 a.initialCommit = commitHash
777
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700778 llmCodeReview := claudetool.NoLLMReview
779 if experiment.Enabled("llm_review") {
780 llmCodeReview = claudetool.DoLLMReview
781 }
782 codereview, err := claudetool.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit, llmCodeReview)
Earl Lee2e463fb2025-04-17 11:22:22 -0700783 if err != nil {
784 return fmt.Errorf("Agent.Init: claudetool.NewCodeReviewer: %w", err)
785 }
786 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000787
788 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700789 }
790 a.lastHEAD = a.initialCommit
791 a.convo = a.initConvo()
792 close(a.ready)
793 return nil
794}
795
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700796//go:embed agent_system_prompt.txt
797var agentSystemPrompt string
798
Earl Lee2e463fb2025-04-17 11:22:22 -0700799// initConvo initializes the conversation.
800// It must not be called until all agent fields are initialized,
801// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700802func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700803 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700804 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700805 convo.PromptCaching = true
806 convo.Budget = a.config.Budget
807
808 var editPrompt string
809 if a.config.UseAnthropicEdit {
810 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."
811 } else {
812 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
813 }
814
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700815 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 -0700816
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000817 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
818 bashPermissionCheck := func(command string) error {
819 // Check if branch name is set
820 a.mu.Lock()
821 branchSet := a.branchName != ""
822 a.mu.Unlock()
823
824 // If branch is set, all commands are allowed
825 if branchSet {
826 return nil
827 }
828
829 // If branch is not set, check if this is a git commit command
830 willCommit, err := bashkit.WillRunGitCommit(command)
831 if err != nil {
832 // If there's an error checking, we should allow the command to proceed
833 return nil
834 }
835
836 // If it's a git commit and branch is not set, return an error
837 if willCommit {
838 return fmt.Errorf("you must use the title tool before making git commits")
839 }
840
841 return nil
842 }
843
844 // Create a custom bash tool with the permission check
845 bashTool := claudetool.NewBashTool(bashPermissionCheck)
846
Earl Lee2e463fb2025-04-17 11:22:22 -0700847 // Register all tools with the conversation
848 // When adding, removing, or modifying tools here, double-check that the termui tool display
849 // template in termui/termui.go has pretty-printing support for all tools.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700850 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000851 bashTool, claudetool.Keyword,
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000852 claudetool.Think, a.preCommitTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
Sean McCullough485afc62025-04-28 14:28:39 -0700853 a.codereview.Tool(), a.multipleChoiceTool(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700854 }
855 if a.config.UseAnthropicEdit {
856 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
857 } else {
858 convo.Tools = append(convo.Tools, claudetool.Patch)
859 }
860 convo.Listener = a
861 return convo
862}
863
Sean McCullough485afc62025-04-28 14:28:39 -0700864func (a *Agent) multipleChoiceTool() *llm.Tool {
865 ret := &llm.Tool{
866 Name: "multiplechoice",
867 Description: "Present the user with an quick way to answer to your question using one of a short list of possible answers you would expect from the user.",
Sean McCullough021557a2025-05-05 23:20:53 +0000868 EndsTurn: true,
Sean McCullough485afc62025-04-28 14:28:39 -0700869 InputSchema: json.RawMessage(`{
870 "type": "object",
871 "description": "The question and a list of answers you would expect the user to choose from.",
872 "properties": {
873 "question": {
874 "type": "string",
875 "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?'"
876 },
877 "responseOptions": {
878 "type": "array",
879 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
880 "items": {
881 "type": "object",
882 "properties": {
883 "caption": {
884 "type": "string",
885 "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'"
886 },
887 "responseText": {
888 "type": "string",
889 "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'"
890 }
891 },
892 "required": ["caption", "responseText"]
893 }
894 }
895 },
896 "required": ["question", "responseOptions"]
897}`),
898 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
899 // The Run logic for "multiplchoice" tool is a no-op on the server.
900 // The UI will present a list of options for the user to select from,
901 // and that's it as far as "executing" the tool_use goes.
902 // When the user *does* select one of the presented options, that
903 // responseText gets sent as a chat message on behalf of the user.
904 return "end your turn and wait for the user to respond", nil
905 },
906 }
907 return ret
908}
909
910type MultipleChoiceOption struct {
911 Caption string `json:"caption"`
912 ResponseText string `json:"responseText"`
913}
914
915type MultipleChoiceParams struct {
916 Question string `json:"question"`
917 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
918}
919
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000920// branchExists reports whether branchName exists, either locally or in well-known remotes.
921func branchExists(dir, branchName string) bool {
922 refs := []string{
923 "refs/heads/",
924 "refs/remotes/origin/",
925 "refs/remotes/sketch-host/",
926 }
927 for _, ref := range refs {
928 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
929 cmd.Dir = dir
930 if cmd.Run() == nil { // exit code 0 means branch exists
931 return true
932 }
933 }
934 return false
935}
936
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000937func (a *Agent) preCommitTool() *llm.Tool {
938 name := "title"
939 description := `Sets the conversation title and creates a git branch for tracking work. MANDATORY: You must use this tool before making any git commits.`
940 if experiment.Enabled("precommit") {
941 name = "precommit"
942 description = `Sets the conversation title, creates a git branch for tracking work, and provides git commit message style guidance. MANDATORY: You must use this tool before making any git commits.`
943 }
944 preCommit := &llm.Tool{
945 Name: name,
946 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -0700947 InputSchema: json.RawMessage(`{
948 "type": "object",
949 "properties": {
950 "title": {
951 "type": "string",
Josh Bleecher Snyder250348e2025-04-30 10:31:28 -0700952 "description": "A concise title summarizing what this conversation is about, imperative tense preferred"
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700953 },
954 "branch_name": {
955 "type": "string",
956 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
Earl Lee2e463fb2025-04-17 11:22:22 -0700957 }
958 },
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700959 "required": ["title", "branch_name"]
Earl Lee2e463fb2025-04-17 11:22:22 -0700960}`),
961 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
962 var params struct {
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700963 Title string `json:"title"`
964 BranchName string `json:"branch_name"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700965 }
966 if err := json.Unmarshal(input, &params); err != nil {
967 return "", err
968 }
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700969 // It's unfortunate to not allow title changes,
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000970 // but it avoids accidentally generating multiple branches.
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700971 t := a.Title()
972 if t != "" {
973 return "", fmt.Errorf("title already set to: %s", t)
974 }
975
976 if params.BranchName == "" {
977 return "", fmt.Errorf("branch_name parameter cannot be empty")
978 }
979 if params.Title == "" {
980 return "", fmt.Errorf("title parameter cannot be empty")
981 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -0700982 if params.BranchName != cleanBranchName(params.BranchName) {
983 return "", fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
984 }
985 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000986 if branchExists(a.workingDir, branchName) {
987 return "", fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
988 }
989
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700990 a.SetTitleBranch(params.Title, branchName)
991
992 response := fmt.Sprintf("Title set to %q, branch name set to %q", params.Title, branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000993
994 if experiment.Enabled("precommit") {
995 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
996 if err != nil {
997 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
998 }
999 if len(styleHint) > 0 {
1000 response += "\n\n" + styleHint
1001 }
1002 }
1003
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001004 return response, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001005 },
1006 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001007 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001008}
1009
1010func (a *Agent) Ready() <-chan struct{} {
1011 return a.ready
1012}
1013
1014func (a *Agent) UserMessage(ctx context.Context, msg string) {
1015 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1016 a.inbox <- msg
1017}
1018
Sean McCullough485afc62025-04-28 14:28:39 -07001019func (a *Agent) ToolResultMessage(ctx context.Context, toolCallID, msg string) {
1020 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg, ToolCallId: toolCallID})
1021 a.inbox <- msg
1022}
1023
Earl Lee2e463fb2025-04-17 11:22:22 -07001024func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1025 return a.convo.CancelToolUse(toolUseID, cause)
1026}
1027
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001028func (a *Agent) CancelTurn(cause error) {
1029 a.cancelTurnMu.Lock()
1030 defer a.cancelTurnMu.Unlock()
1031 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001032 // Force state transition to cancelled state
1033 ctx := a.config.Context
1034 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001035 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001036 }
1037}
1038
1039func (a *Agent) Loop(ctxOuter context.Context) {
1040 for {
1041 select {
1042 case <-ctxOuter.Done():
1043 return
1044 default:
1045 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001046 a.cancelTurnMu.Lock()
1047 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001048 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001049 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001050 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001051 a.cancelTurn = cancel
1052 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001053 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1054 if err != nil {
1055 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1056 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001057 cancel(nil)
1058 }
1059 }
1060}
1061
1062func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1063 if m.Timestamp.IsZero() {
1064 m.Timestamp = time.Now()
1065 }
1066
1067 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1068 if m.EndOfTurn && m.Type == AgentMessageType {
1069 turnDuration := time.Since(a.startOfTurn)
1070 m.TurnDuration = &turnDuration
1071 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1072 }
1073
Earl Lee2e463fb2025-04-17 11:22:22 -07001074 a.mu.Lock()
1075 defer a.mu.Unlock()
1076 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001077 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001078 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001079
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001080 // Notify all subscribers
1081 for _, ch := range a.subscribers {
1082 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001083 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001084}
1085
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001086func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1087 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001088 if block {
1089 select {
1090 case <-ctx.Done():
1091 return m, ctx.Err()
1092 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001093 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001094 }
1095 }
1096 for {
1097 select {
1098 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001099 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001100 default:
1101 return m, nil
1102 }
1103 }
1104}
1105
Sean McCullough885a16a2025-04-30 02:49:25 +00001106// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001107func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001108 // Reset the start of turn time
1109 a.startOfTurn = time.Now()
1110
Sean McCullough96b60dd2025-04-30 09:49:10 -07001111 // Transition to waiting for user input state
1112 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1113
Sean McCullough885a16a2025-04-30 02:49:25 +00001114 // Process initial user message
1115 initialResp, err := a.processUserMessage(ctx)
1116 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001117 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001118 return err
1119 }
1120
1121 // Handle edge case where both initialResp and err are nil
1122 if initialResp == nil {
1123 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001124 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1125
Sean McCullough9f4b8082025-04-30 17:34:07 +00001126 a.pushToOutbox(ctx, errorMessage(err))
1127 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001128 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001129
Earl Lee2e463fb2025-04-17 11:22:22 -07001130 // We do this as we go, but let's also do it at the end of the turn
1131 defer func() {
1132 if _, err := a.handleGitCommits(ctx); err != nil {
1133 // Just log the error, don't stop execution
1134 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1135 }
1136 }()
1137
Sean McCullougha1e0e492025-05-01 10:51:08 -07001138 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001139 resp := initialResp
1140 for {
1141 // Check if we are over budget
1142 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001143 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001144 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001145 }
1146
1147 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001148 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001149 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001150 break
1151 }
1152
Sean McCullough96b60dd2025-04-30 09:49:10 -07001153 // Transition to tool use requested state
1154 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1155
Sean McCullough885a16a2025-04-30 02:49:25 +00001156 // Handle tool execution
1157 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1158 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001159 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001160 }
1161
Sean McCullougha1e0e492025-05-01 10:51:08 -07001162 if toolResp == nil {
1163 return fmt.Errorf("cannot continue conversation with a nil tool response")
1164 }
1165
Sean McCullough885a16a2025-04-30 02:49:25 +00001166 // Set the response for the next iteration
1167 resp = toolResp
1168 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001169
1170 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001171}
1172
1173// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001174func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001175 // Wait for at least one message from the user
1176 msgs, err := a.GatherMessages(ctx, true)
1177 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001178 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001179 return nil, err
1180 }
1181
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001182 userMessage := llm.Message{
1183 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001184 Content: msgs,
1185 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001186
Sean McCullough96b60dd2025-04-30 09:49:10 -07001187 // Transition to sending to LLM state
1188 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1189
Sean McCullough885a16a2025-04-30 02:49:25 +00001190 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001191 resp, err := a.convo.SendMessage(userMessage)
1192 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001193 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001194 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001195 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001196 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001197
Sean McCullough96b60dd2025-04-30 09:49:10 -07001198 // Transition to processing LLM response state
1199 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1200
Sean McCullough885a16a2025-04-30 02:49:25 +00001201 return resp, nil
1202}
1203
1204// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001205func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1206 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001207 cancelled := false
1208
Sean McCullough96b60dd2025-04-30 09:49:10 -07001209 // Transition to checking for cancellation state
1210 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1211
Sean McCullough885a16a2025-04-30 02:49:25 +00001212 // Check if the operation was cancelled by the user
1213 select {
1214 case <-ctx.Done():
1215 // Don't actually run any of the tools, but rather build a response
1216 // for each tool_use message letting the LLM know that user canceled it.
1217 var err error
1218 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001219 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001220 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001221 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001222 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001223 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001224 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001225 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001226 // Transition to running tool state
1227 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1228
Sean McCullough885a16a2025-04-30 02:49:25 +00001229 // Add working directory to context for tool execution
1230 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1231
1232 // Execute the tools
1233 var err error
1234 results, err = a.convo.ToolResultContents(ctx, resp)
1235 if ctx.Err() != nil { // e.g. the user canceled the operation
1236 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001237 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001238 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001239 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001240 a.pushToOutbox(ctx, errorMessage(err))
1241 }
1242 }
1243
1244 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001245 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001246 autoqualityMessages := a.processGitChanges(ctx)
1247
1248 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001249 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001250 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001251 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001252 return false, nil
1253 }
1254
1255 // Continue the conversation with tool results and any user messages
1256 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1257}
1258
1259// processGitChanges checks for new git commits and runs autoformatters if needed
1260func (a *Agent) processGitChanges(ctx context.Context) []string {
1261 // Check for git commits after tool execution
1262 newCommits, err := a.handleGitCommits(ctx)
1263 if err != nil {
1264 // Just log the error, don't stop execution
1265 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1266 return nil
1267 }
1268
1269 // Run autoformatters if there was exactly one new commit
1270 var autoqualityMessages []string
1271 if len(newCommits) == 1 {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001272 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running autoformatters on new commit")
Sean McCullough885a16a2025-04-30 02:49:25 +00001273 formatted := a.codereview.Autoformat(ctx)
1274 if len(formatted) > 0 {
1275 msg := fmt.Sprintf(`
Earl Lee2e463fb2025-04-17 11:22:22 -07001276I ran autoformatters and they updated these files:
1277
1278%s
1279
1280Please amend your latest git commit with these changes and then continue with what you were doing.`,
Sean McCullough885a16a2025-04-30 02:49:25 +00001281 strings.Join(formatted, "\n"),
1282 )[1:]
1283 a.pushToOutbox(ctx, AgentMessage{
1284 Type: AutoMessageType,
1285 Content: msg,
1286 Timestamp: time.Now(),
1287 })
1288 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001289 }
1290 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001291
1292 return autoqualityMessages
1293}
1294
1295// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001296func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001297 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001298 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001299 msgs, err := a.GatherMessages(ctx, false)
1300 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001301 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001302 return false, nil
1303 }
1304
1305 // Inject any auto-generated messages from quality checks
1306 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001307 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001308 }
1309
1310 // Handle cancellation by appending a message about it
1311 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001312 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001313 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001314 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001315 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1316 } else if err := a.convo.OverBudget(); err != nil {
1317 // Handle budget issues by appending a message about it
1318 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 -07001319 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001320 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1321 }
1322
1323 // Combine tool results with user messages
1324 results = append(results, msgs...)
1325
1326 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001327 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001328 resp, err := a.convo.SendMessage(llm.Message{
1329 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001330 Content: results,
1331 })
1332 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001333 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001334 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1335 return true, nil // Return true to continue the conversation, but with no response
1336 }
1337
Sean McCullough96b60dd2025-04-30 09:49:10 -07001338 // Transition back to processing LLM response
1339 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1340
Sean McCullough885a16a2025-04-30 02:49:25 +00001341 if cancelled {
1342 return false, nil
1343 }
1344
1345 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001346}
1347
1348func (a *Agent) overBudget(ctx context.Context) error {
1349 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001350 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001351 m := budgetMessage(err)
1352 m.Content = m.Content + "\n\nBudget reset."
1353 a.pushToOutbox(ctx, budgetMessage(err))
1354 a.convo.ResetBudget(a.originalBudget)
1355 return err
1356 }
1357 return nil
1358}
1359
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001360func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001361 // Collect all text content
1362 var allText strings.Builder
1363 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001364 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001365 if allText.Len() > 0 {
1366 allText.WriteString("\n\n")
1367 }
1368 allText.WriteString(content.Text)
1369 }
1370 }
1371 return allText.String()
1372}
1373
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001374func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001375 a.mu.Lock()
1376 defer a.mu.Unlock()
1377 return a.convo.CumulativeUsage()
1378}
1379
Earl Lee2e463fb2025-04-17 11:22:22 -07001380// Diff returns a unified diff of changes made since the agent was instantiated.
1381func (a *Agent) Diff(commit *string) (string, error) {
1382 if a.initialCommit == "" {
1383 return "", fmt.Errorf("no initial commit reference available")
1384 }
1385
1386 // Find the repository root
1387 ctx := context.Background()
1388
1389 // If a specific commit hash is provided, show just that commit's changes
1390 if commit != nil && *commit != "" {
1391 // Validate that the commit looks like a valid git SHA
1392 if !isValidGitSHA(*commit) {
1393 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1394 }
1395
1396 // Get the diff for just this commit
1397 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1398 cmd.Dir = a.repoRoot
1399 output, err := cmd.CombinedOutput()
1400 if err != nil {
1401 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1402 }
1403 return string(output), nil
1404 }
1405
1406 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1407 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1408 cmd.Dir = a.repoRoot
1409 output, err := cmd.CombinedOutput()
1410 if err != nil {
1411 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1412 }
1413
1414 return string(output), nil
1415}
1416
1417// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1418func (a *Agent) InitialCommit() string {
1419 return a.initialCommit
1420}
1421
1422// handleGitCommits() highlights new commits to the user. When running
1423// under docker, new HEADs are pushed to a branch according to the title.
1424func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1425 if a.repoRoot == "" {
1426 return nil, nil
1427 }
1428
1429 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1430 if err != nil {
1431 return nil, err
1432 }
1433 if head == a.lastHEAD {
1434 return nil, nil // nothing to do
1435 }
1436 defer func() {
1437 a.lastHEAD = head
1438 }()
1439
1440 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1441 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1442 // to the last 100 commits.
1443 var commits []*GitCommit
1444
1445 // Get commits since the initial commit
1446 // Format: <hash>\0<subject>\0<body>\0
1447 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1448 // Limit to 100 commits to avoid overwhelming the user
1449 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1450 cmd.Dir = a.repoRoot
1451 output, err := cmd.Output()
1452 if err != nil {
1453 return nil, fmt.Errorf("failed to get git log: %w", err)
1454 }
1455
1456 // Parse git log output and filter out already seen commits
1457 parsedCommits := parseGitLog(string(output))
1458
1459 var headCommit *GitCommit
1460
1461 // Filter out commits we've already seen
1462 for _, commit := range parsedCommits {
1463 if commit.Hash == head {
1464 headCommit = &commit
1465 }
1466
1467 // Skip if we've seen this commit before. If our head has changed, always include that.
1468 if a.seenCommits[commit.Hash] && commit.Hash != head {
1469 continue
1470 }
1471
1472 // Mark this commit as seen
1473 a.seenCommits[commit.Hash] = true
1474
1475 // Add to our list of new commits
1476 commits = append(commits, &commit)
1477 }
1478
1479 if a.gitRemoteAddr != "" {
1480 if headCommit == nil {
1481 // I think this can only happen if we have a bug or if there's a race.
1482 headCommit = &GitCommit{}
1483 headCommit.Hash = head
1484 headCommit.Subject = "unknown"
1485 commits = append(commits, headCommit)
1486 }
1487
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001488 branch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
Earl Lee2e463fb2025-04-17 11:22:22 -07001489
1490 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1491 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1492 // then use push with lease to replace.
1493 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1494 cmd.Dir = a.workingDir
1495 if out, err := cmd.CombinedOutput(); err != nil {
1496 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1497 } else {
1498 headCommit.PushedBranch = branch
1499 }
1500 }
1501
1502 // If we found new commits, create a message
1503 if len(commits) > 0 {
1504 msg := AgentMessage{
1505 Type: CommitMessageType,
1506 Timestamp: time.Now(),
1507 Commits: commits,
1508 }
1509 a.pushToOutbox(ctx, msg)
1510 }
1511 return commits, nil
1512}
1513
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001514func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001515 return strings.Map(func(r rune) rune {
1516 // lowercase
1517 if r >= 'A' && r <= 'Z' {
1518 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001519 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001520 // replace spaces with dashes
1521 if r == ' ' {
1522 return '-'
1523 }
1524 // allow alphanumerics and dashes
1525 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1526 return r
1527 }
1528 return -1
1529 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001530}
1531
1532// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1533// and returns an array of GitCommit structs.
1534func parseGitLog(output string) []GitCommit {
1535 var commits []GitCommit
1536
1537 // No output means no commits
1538 if len(output) == 0 {
1539 return commits
1540 }
1541
1542 // Split by NULL byte
1543 parts := strings.Split(output, "\x00")
1544
1545 // Process in triplets (hash, subject, body)
1546 for i := 0; i < len(parts); i++ {
1547 // Skip empty parts
1548 if parts[i] == "" {
1549 continue
1550 }
1551
1552 // This should be a hash
1553 hash := strings.TrimSpace(parts[i])
1554
1555 // Make sure we have at least a subject part available
1556 if i+1 >= len(parts) {
1557 break // No more parts available
1558 }
1559
1560 // Get the subject
1561 subject := strings.TrimSpace(parts[i+1])
1562
1563 // Get the body if available
1564 body := ""
1565 if i+2 < len(parts) {
1566 body = strings.TrimSpace(parts[i+2])
1567 }
1568
1569 // Skip to the next triplet
1570 i += 2
1571
1572 commits = append(commits, GitCommit{
1573 Hash: hash,
1574 Subject: subject,
1575 Body: body,
1576 })
1577 }
1578
1579 return commits
1580}
1581
1582func repoRoot(ctx context.Context, dir string) (string, error) {
1583 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1584 stderr := new(strings.Builder)
1585 cmd.Stderr = stderr
1586 cmd.Dir = dir
1587 out, err := cmd.Output()
1588 if err != nil {
1589 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1590 }
1591 return strings.TrimSpace(string(out)), nil
1592}
1593
1594func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1595 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1596 stderr := new(strings.Builder)
1597 cmd.Stderr = stderr
1598 cmd.Dir = dir
1599 out, err := cmd.Output()
1600 if err != nil {
1601 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1602 }
1603 // TODO: validate that out is valid hex
1604 return strings.TrimSpace(string(out)), nil
1605}
1606
1607// isValidGitSHA validates if a string looks like a valid git SHA hash.
1608// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1609func isValidGitSHA(sha string) bool {
1610 // Git SHA must be a hexadecimal string with at least 4 characters
1611 if len(sha) < 4 || len(sha) > 40 {
1612 return false
1613 }
1614
1615 // Check if the string only contains hexadecimal characters
1616 for _, char := range sha {
1617 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1618 return false
1619 }
1620 }
1621
1622 return true
1623}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001624
1625// getGitOrigin returns the URL of the git remote 'origin' if it exists
1626func getGitOrigin(ctx context.Context, dir string) string {
1627 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1628 cmd.Dir = dir
1629 stderr := new(strings.Builder)
1630 cmd.Stderr = stderr
1631 out, err := cmd.Output()
1632 if err != nil {
1633 return ""
1634 }
1635 return strings.TrimSpace(string(out))
1636}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001637
1638func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1639 cmd := exec.CommandContext(ctx, "git", "stash")
1640 cmd.Dir = workingDir
1641 if out, err := cmd.CombinedOutput(); err != nil {
1642 return fmt.Errorf("git stash: %s: %v", out, err)
1643 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001644 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001645 cmd.Dir = workingDir
1646 if out, err := cmd.CombinedOutput(); err != nil {
1647 return fmt.Errorf("git fetch: %s: %w", out, err)
1648 }
1649 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1650 cmd.Dir = workingDir
1651 if out, err := cmd.CombinedOutput(); err != nil {
1652 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1653 }
1654 a.lastHEAD = revision
1655 a.initialCommit = revision
1656 return nil
1657}
1658
1659func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1660 a.mu.Lock()
1661 a.title = ""
1662 a.firstMessageIndex = len(a.history)
1663 a.convo = a.initConvo()
1664 gitReset := func() error {
1665 if a.config.InDocker && rev != "" {
1666 err := a.initGitRevision(ctx, a.workingDir, rev)
1667 if err != nil {
1668 return err
1669 }
1670 } else if !a.config.InDocker && rev != "" {
1671 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1672 }
1673 return nil
1674 }
1675 err := gitReset()
1676 a.mu.Unlock()
1677 if err != nil {
1678 a.pushToOutbox(a.config.Context, errorMessage(err))
1679 }
1680
1681 a.pushToOutbox(a.config.Context, AgentMessage{
1682 Type: AgentMessageType, Content: "Conversation restarted.",
1683 })
1684 if initialPrompt != "" {
1685 a.UserMessage(ctx, initialPrompt)
1686 }
1687 return nil
1688}
1689
1690func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1691 msg := `The user has requested a suggestion for a re-prompt.
1692
1693 Given the current conversation thus far, suggest a re-prompt that would
1694 capture the instructions and feedback so far, as well as any
1695 research or other information that would be helpful in implementing
1696 the task.
1697
1698 Reply with ONLY the reprompt text.
1699 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001700 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001701 // By doing this in a subconversation, the agent doesn't call tools (because
1702 // there aren't any), and there's not a concurrency risk with on-going other
1703 // outstanding conversations.
1704 convo := a.convo.SubConvoWithHistory()
1705 resp, err := convo.SendMessage(userMessage)
1706 if err != nil {
1707 a.pushToOutbox(ctx, errorMessage(err))
1708 return "", err
1709 }
1710 textContent := collectTextContent(resp)
1711 return textContent, nil
1712}