blob: 9577c34e777682521c9f9d8d6c72f6ec93f4fdc8 [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"
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +000018 "text/template"
Earl Lee2e463fb2025-04-17 11:22:22 -070019 "time"
20
Philip Zeyliger33d282f2025-05-03 04:01:54 +000021 "sketch.dev/claudetool/browse"
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000022 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070023 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000024 "sketch.dev/claudetool/bashkit"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000025 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -070026 "sketch.dev/experiment"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070027 "sketch.dev/llm"
28 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070029)
30
31const (
32 userCancelMessage = "user requested agent to stop handling responses"
33)
34
Philip Zeyligerb7c58752025-05-01 10:10:17 -070035type MessageIterator interface {
36 // Next blocks until the next message is available. It may
37 // return nil if the underlying iterator context is done.
38 Next() *AgentMessage
39 Close()
40}
41
Earl Lee2e463fb2025-04-17 11:22:22 -070042type CodingAgent interface {
43 // Init initializes an agent inside a docker container.
44 Init(AgentInit) error
45
46 // Ready returns a channel closed after Init successfully called.
47 Ready() <-chan struct{}
48
49 // URL reports the HTTP URL of this agent.
50 URL() string
51
52 // UserMessage enqueues a message to the agent and returns immediately.
53 UserMessage(ctx context.Context, msg string)
54
Philip Zeyligerb7c58752025-05-01 10:10:17 -070055 // Returns an iterator that finishes when the context is done and
56 // starts with the given message index.
57 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070058
59 // Loop begins the agent loop returns only when ctx is cancelled.
60 Loop(ctx context.Context)
61
Sean McCulloughedc88dc2025-04-30 02:55:01 +000062 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070063
64 CancelToolUse(toolUseID string, cause error) error
65
66 // Returns a subset of the agent's message history.
67 Messages(start int, end int) []AgentMessage
68
69 // Returns the current number of messages in the history
70 MessageCount() int
71
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070072 TotalUsage() conversation.CumulativeUsage
73 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070074
Earl Lee2e463fb2025-04-17 11:22:22 -070075 WorkingDir() string
76
77 // Diff returns a unified diff of changes made since the agent was instantiated.
78 // If commit is non-nil, it shows the diff for just that specific commit.
79 Diff(commit *string) (string, error)
80
81 // InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
82 InitialCommit() string
83
84 // Title returns the current title of the conversation.
85 Title() string
86
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000087 // BranchName returns the git branch name for the conversation.
88 BranchName() string
89
Earl Lee2e463fb2025-04-17 11:22:22 -070090 // OS returns the operating system of the client.
91 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +000092
Philip Zeyligerc72fff52025-04-29 20:17:54 +000093 // SessionID returns the unique session identifier.
94 SessionID() string
95
Philip Zeyliger99a9a022025-04-27 15:15:25 +000096 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
97 OutstandingLLMCallCount() int
98
99 // OutstandingToolCalls returns the names of outstanding tool calls.
100 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000101 OutsideOS() string
102 OutsideHostname() string
103 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000104 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000105 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
106 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700107
108 // RestartConversation resets the conversation history
109 RestartConversation(ctx context.Context, rev string, initialPrompt string) error
110 // SuggestReprompt suggests a re-prompt based on the current conversation.
111 SuggestReprompt(ctx context.Context) (string, error)
112 // IsInContainer returns true if the agent is running in a container
113 IsInContainer() bool
114 // FirstMessageIndex returns the index of the first message in the current conversation
115 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700116
117 CurrentStateName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700118}
119
120type CodingAgentMessageType string
121
122const (
123 UserMessageType CodingAgentMessageType = "user"
124 AgentMessageType CodingAgentMessageType = "agent"
125 ErrorMessageType CodingAgentMessageType = "error"
126 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
127 ToolUseMessageType CodingAgentMessageType = "tool"
128 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
129 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
130
131 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
132)
133
134type AgentMessage struct {
135 Type CodingAgentMessageType `json:"type"`
136 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
137 EndOfTurn bool `json:"end_of_turn"`
138
139 Content string `json:"content"`
140 ToolName string `json:"tool_name,omitempty"`
141 ToolInput string `json:"input,omitempty"`
142 ToolResult string `json:"tool_result,omitempty"`
143 ToolError bool `json:"tool_error,omitempty"`
144 ToolCallId string `json:"tool_call_id,omitempty"`
145
146 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
147 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
148
Sean McCulloughd9f13372025-04-21 15:08:49 -0700149 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
150 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
151
Earl Lee2e463fb2025-04-17 11:22:22 -0700152 // Commits is a list of git commits for a commit message
153 Commits []*GitCommit `json:"commits,omitempty"`
154
155 Timestamp time.Time `json:"timestamp"`
156 ConversationID string `json:"conversation_id"`
157 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700158 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700159
160 // Message timing information
161 StartTime *time.Time `json:"start_time,omitempty"`
162 EndTime *time.Time `json:"end_time,omitempty"`
163 Elapsed *time.Duration `json:"elapsed,omitempty"`
164
165 // Turn duration - the time taken for a complete agent turn
166 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
167
168 Idx int `json:"idx"`
169}
170
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700171// SetConvo sets m.ConversationID and m.ParentConversationID based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700172func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700173 if convo == nil {
174 m.ConversationID = ""
175 m.ParentConversationID = nil
176 return
177 }
178 m.ConversationID = convo.ID
179 if convo.Parent != nil {
180 m.ParentConversationID = &convo.Parent.ID
181 }
182}
183
Earl Lee2e463fb2025-04-17 11:22:22 -0700184// GitCommit represents a single git commit for a commit message
185type GitCommit struct {
186 Hash string `json:"hash"` // Full commit hash
187 Subject string `json:"subject"` // Commit subject line
188 Body string `json:"body"` // Full commit message body
189 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
190}
191
192// ToolCall represents a single tool call within an agent message
193type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700194 Name string `json:"name"`
195 Input string `json:"input"`
196 ToolCallId string `json:"tool_call_id"`
197 ResultMessage *AgentMessage `json:"result_message,omitempty"`
198 Args string `json:"args,omitempty"`
199 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700200}
201
202func (a *AgentMessage) Attr() slog.Attr {
203 var attrs []any = []any{
204 slog.String("type", string(a.Type)),
205 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700206 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700207 if a.EndOfTurn {
208 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
209 }
210 if a.Content != "" {
211 attrs = append(attrs, slog.String("content", a.Content))
212 }
213 if a.ToolName != "" {
214 attrs = append(attrs, slog.String("tool_name", a.ToolName))
215 }
216 if a.ToolInput != "" {
217 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
218 }
219 if a.Elapsed != nil {
220 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
221 }
222 if a.TurnDuration != nil {
223 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
224 }
225 if a.ToolResult != "" {
226 attrs = append(attrs, slog.String("tool_result", a.ToolResult))
227 }
228 if a.ToolError {
229 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
230 }
231 if len(a.ToolCalls) > 0 {
232 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
233 for i, tc := range a.ToolCalls {
234 toolCallAttrs = append(toolCallAttrs, slog.Group(
235 fmt.Sprintf("tool_call_%d", i),
236 slog.String("name", tc.Name),
237 slog.String("input", tc.Input),
238 ))
239 }
240 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
241 }
242 if a.ConversationID != "" {
243 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
244 }
245 if a.ParentConversationID != nil {
246 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
247 }
248 if a.Usage != nil && !a.Usage.IsZero() {
249 attrs = append(attrs, a.Usage.Attr())
250 }
251 // TODO: timestamp, convo ids, idx?
252 return slog.Group("agent_message", attrs...)
253}
254
255func errorMessage(err error) AgentMessage {
256 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
257 if os.Getenv(("DEBUG")) == "1" {
258 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
259 }
260
261 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
262}
263
264func budgetMessage(err error) AgentMessage {
265 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
266}
267
268// ConvoInterface defines the interface for conversation interactions
269type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700270 CumulativeUsage() conversation.CumulativeUsage
271 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700272 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700273 SendMessage(message llm.Message) (*llm.Response, error)
274 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700275 GetID() string
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700276 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, error)
277 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700278 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700279 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700280}
281
282type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700283 convo ConvoInterface
284 config AgentConfig // config for this agent
285 workingDir string
286 repoRoot string // workingDir may be a subdir of repoRoot
287 url string
288 firstMessageIndex int // index of the first message in the current conversation
289 lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
290 initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
291 gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000292 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700293 ready chan struct{} // closed when the agent is initialized (only when under docker)
294 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700295 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700296 title string
297 branchName string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000298 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700299 // State machine to track agent state
300 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000301 // Outside information
302 outsideHostname string
303 outsideOS string
304 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000305 // URL of the git remote 'origin' if it exists
306 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700307
308 // Time when the current turn started (reset at the beginning of InnerLoop)
309 startOfTurn time.Time
310
311 // Inbox - for messages from the user to the agent.
312 // sent on by UserMessage
313 // . e.g. when user types into the chat textarea
314 // read from by GatherMessages
315 inbox chan string
316
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000317 // protects cancelTurn
318 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700319 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000320 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700321
322 // protects following
323 mu sync.Mutex
324
325 // Stores all messages for this agent
326 history []AgentMessage
327
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700328 // Iterators add themselves here when they're ready to be notified of new messages.
329 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700330
331 // Track git commits we've already seen (by hash)
332 seenCommits map[string]bool
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000333
334 // Track outstanding LLM call IDs
335 outstandingLLMCalls map[string]struct{}
336
337 // Track outstanding tool calls by ID with their names
338 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700339}
340
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700341// NewIterator implements CodingAgent.
342func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
343 a.mu.Lock()
344 defer a.mu.Unlock()
345
346 return &MessageIteratorImpl{
347 agent: a,
348 ctx: ctx,
349 nextMessageIdx: nextMessageIdx,
350 ch: make(chan *AgentMessage, 100),
351 }
352}
353
354type MessageIteratorImpl struct {
355 agent *Agent
356 ctx context.Context
357 nextMessageIdx int
358 ch chan *AgentMessage
359 subscribed bool
360}
361
362func (m *MessageIteratorImpl) Close() {
363 m.agent.mu.Lock()
364 defer m.agent.mu.Unlock()
365 // Delete ourselves from the subscribers list
366 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
367 return x == m.ch
368 })
369 close(m.ch)
370}
371
372func (m *MessageIteratorImpl) Next() *AgentMessage {
373 // We avoid subscription at creation to let ourselves catch up to "current state"
374 // before subscribing.
375 if !m.subscribed {
376 m.agent.mu.Lock()
377 if m.nextMessageIdx < len(m.agent.history) {
378 msg := &m.agent.history[m.nextMessageIdx]
379 m.nextMessageIdx++
380 m.agent.mu.Unlock()
381 return msg
382 }
383 // The next message doesn't exist yet, so let's subscribe
384 m.agent.subscribers = append(m.agent.subscribers, m.ch)
385 m.subscribed = true
386 m.agent.mu.Unlock()
387 }
388
389 for {
390 select {
391 case <-m.ctx.Done():
392 m.agent.mu.Lock()
393 // Delete ourselves from the subscribers list
394 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
395 return x == m.ch
396 })
397 m.subscribed = false
398 m.agent.mu.Unlock()
399 return nil
400 case msg, ok := <-m.ch:
401 if !ok {
402 // Close may have been called
403 return nil
404 }
405 if msg.Idx == m.nextMessageIdx {
406 m.nextMessageIdx++
407 return msg
408 }
409 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
410 panic("out of order message")
411 }
412 }
413}
414
Sean McCulloughd9d45812025-04-30 16:53:41 -0700415// Assert that Agent satisfies the CodingAgent interface.
416var _ CodingAgent = &Agent{}
417
418// StateName implements CodingAgent.
419func (a *Agent) CurrentStateName() string {
420 if a.stateMachine == nil {
421 return ""
422 }
423 return a.stateMachine.currentState.String()
424}
425
Earl Lee2e463fb2025-04-17 11:22:22 -0700426func (a *Agent) URL() string { return a.url }
427
428// Title returns the current title of the conversation.
429// If no title has been set, returns an empty string.
430func (a *Agent) Title() string {
431 a.mu.Lock()
432 defer a.mu.Unlock()
433 return a.title
434}
435
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000436// BranchName returns the git branch name for the conversation.
437func (a *Agent) BranchName() string {
438 a.mu.Lock()
439 defer a.mu.Unlock()
440 return a.branchName
441}
442
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000443// OutstandingLLMCallCount returns the number of outstanding LLM calls.
444func (a *Agent) OutstandingLLMCallCount() int {
445 a.mu.Lock()
446 defer a.mu.Unlock()
447 return len(a.outstandingLLMCalls)
448}
449
450// OutstandingToolCalls returns the names of outstanding tool calls.
451func (a *Agent) OutstandingToolCalls() []string {
452 a.mu.Lock()
453 defer a.mu.Unlock()
454
455 tools := make([]string, 0, len(a.outstandingToolCalls))
456 for _, toolName := range a.outstandingToolCalls {
457 tools = append(tools, toolName)
458 }
459 return tools
460}
461
Earl Lee2e463fb2025-04-17 11:22:22 -0700462// OS returns the operating system of the client.
463func (a *Agent) OS() string {
464 return a.config.ClientGOOS
465}
466
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000467func (a *Agent) SessionID() string {
468 return a.config.SessionID
469}
470
Philip Zeyliger18532b22025-04-23 21:11:46 +0000471// OutsideOS returns the operating system of the outside system.
472func (a *Agent) OutsideOS() string {
473 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000474}
475
Philip Zeyliger18532b22025-04-23 21:11:46 +0000476// OutsideHostname returns the hostname of the outside system.
477func (a *Agent) OutsideHostname() string {
478 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000479}
480
Philip Zeyliger18532b22025-04-23 21:11:46 +0000481// OutsideWorkingDir returns the working directory on the outside system.
482func (a *Agent) OutsideWorkingDir() string {
483 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000484}
485
486// GitOrigin returns the URL of the git remote 'origin' if it exists.
487func (a *Agent) GitOrigin() string {
488 return a.gitOrigin
489}
490
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000491func (a *Agent) OpenBrowser(url string) {
492 if !a.IsInContainer() {
493 browser.Open(url)
494 return
495 }
496 // We're in Docker, need to send a request to the Git server
497 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700498 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000499 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700500 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000501 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700502 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000503 return
504 }
505 defer resp.Body.Close()
506 if resp.StatusCode == http.StatusOK {
507 return
508 }
509 body, _ := io.ReadAll(resp.Body)
510 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
511}
512
Sean McCullough96b60dd2025-04-30 09:49:10 -0700513// CurrentState returns the current state of the agent's state machine.
514func (a *Agent) CurrentState() State {
515 return a.stateMachine.CurrentState()
516}
517
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700518func (a *Agent) IsInContainer() bool {
519 return a.config.InDocker
520}
521
522func (a *Agent) FirstMessageIndex() int {
523 a.mu.Lock()
524 defer a.mu.Unlock()
525 return a.firstMessageIndex
526}
527
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700528// SetTitleBranch sets the title and branch name of the conversation.
529func (a *Agent) SetTitleBranch(title, branchName string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700530 a.mu.Lock()
531 defer a.mu.Unlock()
532 a.title = title
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700533 a.branchName = branchName
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700534
535 // TODO: We could potentially notify listeners of a state change, but,
536 // realistically, a new message will be sent for the tool result as well.
Earl Lee2e463fb2025-04-17 11:22:22 -0700537}
538
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000539// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700540func (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 +0000541 // Track the tool call
542 a.mu.Lock()
543 a.outstandingToolCalls[id] = toolName
544 a.mu.Unlock()
545}
546
Earl Lee2e463fb2025-04-17 11:22:22 -0700547// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700548func (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 +0000549 // Remove the tool call from outstanding calls
550 a.mu.Lock()
551 delete(a.outstandingToolCalls, toolID)
552 a.mu.Unlock()
553
Earl Lee2e463fb2025-04-17 11:22:22 -0700554 m := AgentMessage{
555 Type: ToolUseMessageType,
556 Content: content.Text,
557 ToolResult: content.ToolResult,
558 ToolError: content.ToolError,
559 ToolName: toolName,
560 ToolInput: string(toolInput),
561 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700562 StartTime: content.ToolUseStartTime,
563 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700564 }
565
566 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700567 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
568 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700569 m.Elapsed = &elapsed
570 }
571
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700572 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700573 a.pushToOutbox(ctx, m)
574}
575
576// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700577func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000578 a.mu.Lock()
579 defer a.mu.Unlock()
580 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700581 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
582}
583
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700584// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700585// that need to be displayed (as well as tool calls that we send along when
586// they're done). (It would be reasonable to also mention tool calls when they're
587// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700588func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000589 // Remove the LLM call from outstanding calls
590 a.mu.Lock()
591 delete(a.outstandingLLMCalls, id)
592 a.mu.Unlock()
593
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700594 if resp == nil {
595 // LLM API call failed
596 m := AgentMessage{
597 Type: ErrorMessageType,
598 Content: "API call failed, type 'continue' to try again",
599 }
600 m.SetConvo(convo)
601 a.pushToOutbox(ctx, m)
602 return
603 }
604
Earl Lee2e463fb2025-04-17 11:22:22 -0700605 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700606 if convo.Parent == nil { // subconvos never end the turn
607 switch resp.StopReason {
608 case llm.StopReasonToolUse:
609 // Check whether any of the tool calls are for tools that should end the turn
610 ToolSearch:
611 for _, part := range resp.Content {
612 if part.Type != llm.ContentTypeToolUse {
613 continue
614 }
Sean McCullough021557a2025-05-05 23:20:53 +0000615 // Find the tool by name
616 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700617 if tool.Name == part.ToolName {
618 endOfTurn = tool.EndsTurn
619 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000620 }
621 }
Sean McCullough021557a2025-05-05 23:20:53 +0000622 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700623 default:
624 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000625 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700626 }
627 m := AgentMessage{
628 Type: AgentMessageType,
629 Content: collectTextContent(resp),
630 EndOfTurn: endOfTurn,
631 Usage: &resp.Usage,
632 StartTime: resp.StartTime,
633 EndTime: resp.EndTime,
634 }
635
636 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700637 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700638 var toolCalls []ToolCall
639 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700640 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700641 toolCalls = append(toolCalls, ToolCall{
642 Name: part.ToolName,
643 Input: string(part.ToolInput),
644 ToolCallId: part.ID,
645 })
646 }
647 }
648 m.ToolCalls = toolCalls
649 }
650
651 // Calculate the elapsed time if both start and end times are set
652 if resp.StartTime != nil && resp.EndTime != nil {
653 elapsed := resp.EndTime.Sub(*resp.StartTime)
654 m.Elapsed = &elapsed
655 }
656
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700657 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700658 a.pushToOutbox(ctx, m)
659}
660
661// WorkingDir implements CodingAgent.
662func (a *Agent) WorkingDir() string {
663 return a.workingDir
664}
665
666// MessageCount implements CodingAgent.
667func (a *Agent) MessageCount() int {
668 a.mu.Lock()
669 defer a.mu.Unlock()
670 return len(a.history)
671}
672
673// Messages implements CodingAgent.
674func (a *Agent) Messages(start int, end int) []AgentMessage {
675 a.mu.Lock()
676 defer a.mu.Unlock()
677 return slices.Clone(a.history[start:end])
678}
679
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700680func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700681 return a.originalBudget
682}
683
684// AgentConfig contains configuration for creating a new Agent.
685type AgentConfig struct {
686 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700687 Service llm.Service
688 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700689 GitUsername string
690 GitEmail string
691 SessionID string
692 ClientGOOS string
693 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700694 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700695 UseAnthropicEdit bool
Philip Zeyliger18532b22025-04-23 21:11:46 +0000696 // Outside information
697 OutsideHostname string
698 OutsideOS string
699 OutsideWorkingDir string
Earl Lee2e463fb2025-04-17 11:22:22 -0700700}
701
702// NewAgent creates a new Agent.
703// It is not usable until Init() is called.
704func NewAgent(config AgentConfig) *Agent {
705 agent := &Agent{
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000706 config: config,
707 ready: make(chan struct{}),
708 inbox: make(chan string, 100),
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700709 subscribers: make([]chan *AgentMessage, 0),
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000710 startedAt: time.Now(),
711 originalBudget: config.Budget,
712 seenCommits: make(map[string]bool),
713 outsideHostname: config.OutsideHostname,
714 outsideOS: config.OutsideOS,
715 outsideWorkingDir: config.OutsideWorkingDir,
716 outstandingLLMCalls: make(map[string]struct{}),
717 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700718 stateMachine: NewStateMachine(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700719 }
720 return agent
721}
722
723type AgentInit struct {
724 WorkingDir string
725 NoGit bool // only for testing
726
727 InDocker bool
728 Commit string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000729 OutsideHTTP string
Earl Lee2e463fb2025-04-17 11:22:22 -0700730 GitRemoteAddr string
731 HostAddr string
732}
733
734func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700735 if a.convo != nil {
736 return fmt.Errorf("Agent.Init: already initialized")
737 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700738 ctx := a.config.Context
739 if ini.InDocker {
740 cmd := exec.CommandContext(ctx, "git", "stash")
741 cmd.Dir = ini.WorkingDir
742 if out, err := cmd.CombinedOutput(); err != nil {
743 return fmt.Errorf("git stash: %s: %v", out, err)
744 }
Philip Zeyligerd0ac1ea2025-04-21 20:04:19 -0700745 cmd = exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", ini.GitRemoteAddr)
746 cmd.Dir = ini.WorkingDir
747 if out, err := cmd.CombinedOutput(); err != nil {
748 return fmt.Errorf("git remote add: %s: %v", out, err)
749 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000750 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Earl Lee2e463fb2025-04-17 11:22:22 -0700751 cmd.Dir = ini.WorkingDir
752 if out, err := cmd.CombinedOutput(); err != nil {
753 return fmt.Errorf("git fetch: %s: %w", out, err)
754 }
755 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", ini.Commit)
756 cmd.Dir = ini.WorkingDir
757 if out, err := cmd.CombinedOutput(); err != nil {
758 return fmt.Errorf("git checkout %s: %s: %w", ini.Commit, out, err)
759 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700760 a.lastHEAD = ini.Commit
761 a.gitRemoteAddr = ini.GitRemoteAddr
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000762 a.outsideHTTP = ini.OutsideHTTP
Earl Lee2e463fb2025-04-17 11:22:22 -0700763 a.initialCommit = ini.Commit
764 if ini.HostAddr != "" {
765 a.url = "http://" + ini.HostAddr
766 }
767 }
768 a.workingDir = ini.WorkingDir
769
770 if !ini.NoGit {
771 repoRoot, err := repoRoot(ctx, a.workingDir)
772 if err != nil {
773 return fmt.Errorf("repoRoot: %w", err)
774 }
775 a.repoRoot = repoRoot
776
777 commitHash, err := resolveRef(ctx, a.repoRoot, "HEAD")
778 if err != nil {
779 return fmt.Errorf("resolveRef: %w", err)
780 }
781 a.initialCommit = commitHash
782
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000783 llmCodeReview := codereview.NoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700784 if experiment.Enabled("llm_review") {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000785 llmCodeReview = codereview.DoLLMReview
Josh Bleecher Snydere2518e52025-04-29 11:13:40 -0700786 }
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000787 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.initialCommit, llmCodeReview)
Earl Lee2e463fb2025-04-17 11:22:22 -0700788 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000789 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700790 }
791 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000792
793 a.gitOrigin = getGitOrigin(ctx, ini.WorkingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700794 }
795 a.lastHEAD = a.initialCommit
796 a.convo = a.initConvo()
797 close(a.ready)
798 return nil
799}
800
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700801//go:embed agent_system_prompt.txt
802var agentSystemPrompt string
803
Earl Lee2e463fb2025-04-17 11:22:22 -0700804// initConvo initializes the conversation.
805// It must not be called until all agent fields are initialized,
806// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700807func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700808 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700809 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700810 convo.PromptCaching = true
811 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000812 convo.SystemPrompt = a.renderSystemPrompt()
Earl Lee2e463fb2025-04-17 11:22:22 -0700813
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000814 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
815 bashPermissionCheck := func(command string) error {
816 // Check if branch name is set
817 a.mu.Lock()
818 branchSet := a.branchName != ""
819 a.mu.Unlock()
820
821 // If branch is set, all commands are allowed
822 if branchSet {
823 return nil
824 }
825
826 // If branch is not set, check if this is a git commit command
827 willCommit, err := bashkit.WillRunGitCommit(command)
828 if err != nil {
829 // If there's an error checking, we should allow the command to proceed
830 return nil
831 }
832
833 // If it's a git commit and branch is not set, return an error
834 if willCommit {
835 return fmt.Errorf("you must use the title tool before making git commits")
836 }
837
838 return nil
839 }
840
841 // Create a custom bash tool with the permission check
842 bashTool := claudetool.NewBashTool(bashPermissionCheck)
843
Earl Lee2e463fb2025-04-17 11:22:22 -0700844 // Register all tools with the conversation
845 // When adding, removing, or modifying tools here, double-check that the termui tool display
846 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000847
848 var browserTools []*llm.Tool
849 // Add browser tools if enabled
850 // if experiment.Enabled("browser") {
851 if true {
852 bTools, browserCleanup := browse.RegisterBrowserTools(a.config.Context)
853 // Add cleanup function to context cancel
854 go func() {
855 <-a.config.Context.Done()
856 browserCleanup()
857 }()
858 browserTools = bTools
859 }
860
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700861 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000862 bashTool, claudetool.Keyword,
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000863 claudetool.Think, a.preCommitTool(), makeDoneTool(a.codereview, a.config.GitUsername, a.config.GitEmail),
Sean McCullough485afc62025-04-28 14:28:39 -0700864 a.codereview.Tool(), a.multipleChoiceTool(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700865 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000866
867 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -0700868 if a.config.UseAnthropicEdit {
869 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
870 } else {
871 convo.Tools = append(convo.Tools, claudetool.Patch)
872 }
873 convo.Listener = a
874 return convo
875}
876
Sean McCullough485afc62025-04-28 14:28:39 -0700877func (a *Agent) multipleChoiceTool() *llm.Tool {
878 ret := &llm.Tool{
879 Name: "multiplechoice",
880 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 +0000881 EndsTurn: true,
Sean McCullough485afc62025-04-28 14:28:39 -0700882 InputSchema: json.RawMessage(`{
883 "type": "object",
884 "description": "The question and a list of answers you would expect the user to choose from.",
885 "properties": {
886 "question": {
887 "type": "string",
888 "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?'"
889 },
890 "responseOptions": {
891 "type": "array",
892 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
893 "items": {
894 "type": "object",
895 "properties": {
896 "caption": {
897 "type": "string",
898 "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'"
899 },
900 "responseText": {
901 "type": "string",
902 "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'"
903 }
904 },
905 "required": ["caption", "responseText"]
906 }
907 }
908 },
909 "required": ["question", "responseOptions"]
910}`),
911 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
912 // The Run logic for "multiplchoice" tool is a no-op on the server.
913 // The UI will present a list of options for the user to select from,
914 // and that's it as far as "executing" the tool_use goes.
915 // When the user *does* select one of the presented options, that
916 // responseText gets sent as a chat message on behalf of the user.
917 return "end your turn and wait for the user to respond", nil
918 },
919 }
920 return ret
921}
922
923type MultipleChoiceOption struct {
924 Caption string `json:"caption"`
925 ResponseText string `json:"responseText"`
926}
927
928type MultipleChoiceParams struct {
929 Question string `json:"question"`
930 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
931}
932
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000933// branchExists reports whether branchName exists, either locally or in well-known remotes.
934func branchExists(dir, branchName string) bool {
935 refs := []string{
936 "refs/heads/",
937 "refs/remotes/origin/",
938 "refs/remotes/sketch-host/",
939 }
940 for _, ref := range refs {
941 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
942 cmd.Dir = dir
943 if cmd.Run() == nil { // exit code 0 means branch exists
944 return true
945 }
946 }
947 return false
948}
949
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000950func (a *Agent) preCommitTool() *llm.Tool {
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000951 description := `Sets the conversation title and creates a git branch for tracking work. MANDATORY: You must use this tool before making any git commits.`
952 if experiment.Enabled("precommit") {
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000953 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.`
954 }
955 preCommit := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -0700956 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000957 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -0700958 InputSchema: json.RawMessage(`{
959 "type": "object",
960 "properties": {
961 "title": {
962 "type": "string",
Josh Bleecher Snyder250348e2025-04-30 10:31:28 -0700963 "description": "A concise title summarizing what this conversation is about, imperative tense preferred"
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700964 },
965 "branch_name": {
966 "type": "string",
967 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
Earl Lee2e463fb2025-04-17 11:22:22 -0700968 }
969 },
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700970 "required": ["title", "branch_name"]
Earl Lee2e463fb2025-04-17 11:22:22 -0700971}`),
972 Run: func(ctx context.Context, input json.RawMessage) (string, error) {
973 var params struct {
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700974 Title string `json:"title"`
975 BranchName string `json:"branch_name"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700976 }
977 if err := json.Unmarshal(input, &params); err != nil {
978 return "", err
979 }
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700980 // It's unfortunate to not allow title changes,
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +0000981 // but it avoids accidentally generating multiple branches.
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -0700982 t := a.Title()
983 if t != "" {
984 return "", fmt.Errorf("title already set to: %s", t)
985 }
986
987 if params.BranchName == "" {
988 return "", fmt.Errorf("branch_name parameter cannot be empty")
989 }
990 if params.Title == "" {
991 return "", fmt.Errorf("title parameter cannot be empty")
992 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -0700993 if params.BranchName != cleanBranchName(params.BranchName) {
994 return "", fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
995 }
996 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +0000997 if branchExists(a.workingDir, branchName) {
998 return "", fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
999 }
1000
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001001 a.SetTitleBranch(params.Title, branchName)
1002
1003 response := fmt.Sprintf("Title set to %q, branch name set to %q", params.Title, branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001004
1005 if experiment.Enabled("precommit") {
1006 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1007 if err != nil {
1008 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1009 }
1010 if len(styleHint) > 0 {
1011 response += "\n\n" + styleHint
1012 }
1013 }
1014
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001015 return response, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001016 },
1017 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001018 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001019}
1020
1021func (a *Agent) Ready() <-chan struct{} {
1022 return a.ready
1023}
1024
1025func (a *Agent) UserMessage(ctx context.Context, msg string) {
1026 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1027 a.inbox <- msg
1028}
1029
Sean McCullough485afc62025-04-28 14:28:39 -07001030func (a *Agent) ToolResultMessage(ctx context.Context, toolCallID, msg string) {
1031 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg, ToolCallId: toolCallID})
1032 a.inbox <- msg
1033}
1034
Earl Lee2e463fb2025-04-17 11:22:22 -07001035func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1036 return a.convo.CancelToolUse(toolUseID, cause)
1037}
1038
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001039func (a *Agent) CancelTurn(cause error) {
1040 a.cancelTurnMu.Lock()
1041 defer a.cancelTurnMu.Unlock()
1042 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001043 // Force state transition to cancelled state
1044 ctx := a.config.Context
1045 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001046 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001047 }
1048}
1049
1050func (a *Agent) Loop(ctxOuter context.Context) {
1051 for {
1052 select {
1053 case <-ctxOuter.Done():
1054 return
1055 default:
1056 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001057 a.cancelTurnMu.Lock()
1058 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001059 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001060 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001061 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001062 a.cancelTurn = cancel
1063 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001064 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1065 if err != nil {
1066 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1067 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001068 cancel(nil)
1069 }
1070 }
1071}
1072
1073func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1074 if m.Timestamp.IsZero() {
1075 m.Timestamp = time.Now()
1076 }
1077
1078 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1079 if m.EndOfTurn && m.Type == AgentMessageType {
1080 turnDuration := time.Since(a.startOfTurn)
1081 m.TurnDuration = &turnDuration
1082 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1083 }
1084
Earl Lee2e463fb2025-04-17 11:22:22 -07001085 a.mu.Lock()
1086 defer a.mu.Unlock()
1087 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001088 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001089 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001090
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001091 // Notify all subscribers
1092 for _, ch := range a.subscribers {
1093 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001094 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001095}
1096
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001097func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1098 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001099 if block {
1100 select {
1101 case <-ctx.Done():
1102 return m, ctx.Err()
1103 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001104 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001105 }
1106 }
1107 for {
1108 select {
1109 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001110 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001111 default:
1112 return m, nil
1113 }
1114 }
1115}
1116
Sean McCullough885a16a2025-04-30 02:49:25 +00001117// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001118func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001119 // Reset the start of turn time
1120 a.startOfTurn = time.Now()
1121
Sean McCullough96b60dd2025-04-30 09:49:10 -07001122 // Transition to waiting for user input state
1123 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1124
Sean McCullough885a16a2025-04-30 02:49:25 +00001125 // Process initial user message
1126 initialResp, err := a.processUserMessage(ctx)
1127 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001128 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001129 return err
1130 }
1131
1132 // Handle edge case where both initialResp and err are nil
1133 if initialResp == nil {
1134 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001135 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1136
Sean McCullough9f4b8082025-04-30 17:34:07 +00001137 a.pushToOutbox(ctx, errorMessage(err))
1138 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001139 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001140
Earl Lee2e463fb2025-04-17 11:22:22 -07001141 // We do this as we go, but let's also do it at the end of the turn
1142 defer func() {
1143 if _, err := a.handleGitCommits(ctx); err != nil {
1144 // Just log the error, don't stop execution
1145 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1146 }
1147 }()
1148
Sean McCullougha1e0e492025-05-01 10:51:08 -07001149 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001150 resp := initialResp
1151 for {
1152 // Check if we are over budget
1153 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001154 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001155 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001156 }
1157
1158 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001159 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001160 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001161 break
1162 }
1163
Sean McCullough96b60dd2025-04-30 09:49:10 -07001164 // Transition to tool use requested state
1165 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1166
Sean McCullough885a16a2025-04-30 02:49:25 +00001167 // Handle tool execution
1168 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1169 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001170 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001171 }
1172
Sean McCullougha1e0e492025-05-01 10:51:08 -07001173 if toolResp == nil {
1174 return fmt.Errorf("cannot continue conversation with a nil tool response")
1175 }
1176
Sean McCullough885a16a2025-04-30 02:49:25 +00001177 // Set the response for the next iteration
1178 resp = toolResp
1179 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001180
1181 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001182}
1183
1184// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001185func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001186 // Wait for at least one message from the user
1187 msgs, err := a.GatherMessages(ctx, true)
1188 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001189 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001190 return nil, err
1191 }
1192
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001193 userMessage := llm.Message{
1194 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001195 Content: msgs,
1196 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001197
Sean McCullough96b60dd2025-04-30 09:49:10 -07001198 // Transition to sending to LLM state
1199 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1200
Sean McCullough885a16a2025-04-30 02:49:25 +00001201 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001202 resp, err := a.convo.SendMessage(userMessage)
1203 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001204 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001205 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001206 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001207 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001208
Sean McCullough96b60dd2025-04-30 09:49:10 -07001209 // Transition to processing LLM response state
1210 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1211
Sean McCullough885a16a2025-04-30 02:49:25 +00001212 return resp, nil
1213}
1214
1215// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001216func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1217 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001218 cancelled := false
1219
Sean McCullough96b60dd2025-04-30 09:49:10 -07001220 // Transition to checking for cancellation state
1221 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1222
Sean McCullough885a16a2025-04-30 02:49:25 +00001223 // Check if the operation was cancelled by the user
1224 select {
1225 case <-ctx.Done():
1226 // Don't actually run any of the tools, but rather build a response
1227 // for each tool_use message letting the LLM know that user canceled it.
1228 var err error
1229 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001230 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001231 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001232 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001233 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001234 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001235 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001236 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001237 // Transition to running tool state
1238 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1239
Sean McCullough885a16a2025-04-30 02:49:25 +00001240 // Add working directory to context for tool execution
1241 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1242
1243 // Execute the tools
1244 var err error
1245 results, err = a.convo.ToolResultContents(ctx, resp)
1246 if ctx.Err() != nil { // e.g. the user canceled the operation
1247 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001248 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001249 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001250 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001251 a.pushToOutbox(ctx, errorMessage(err))
1252 }
1253 }
1254
1255 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001256 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001257 autoqualityMessages := a.processGitChanges(ctx)
1258
1259 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001260 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001261 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001262 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001263 return false, nil
1264 }
1265
1266 // Continue the conversation with tool results and any user messages
1267 return a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1268}
1269
1270// processGitChanges checks for new git commits and runs autoformatters if needed
1271func (a *Agent) processGitChanges(ctx context.Context) []string {
1272 // Check for git commits after tool execution
1273 newCommits, err := a.handleGitCommits(ctx)
1274 if err != nil {
1275 // Just log the error, don't stop execution
1276 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1277 return nil
1278 }
1279
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001280 // Run mechanical checks if there was exactly one new commit.
1281 if len(newCommits) != 1 {
1282 return nil
1283 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001284 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001285 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1286 msg := a.codereview.RunMechanicalChecks(ctx)
1287 if msg != "" {
1288 a.pushToOutbox(ctx, AgentMessage{
1289 Type: AutoMessageType,
1290 Content: msg,
1291 Timestamp: time.Now(),
1292 })
1293 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001294 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001295
1296 return autoqualityMessages
1297}
1298
1299// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001300func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001301 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001302 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001303 msgs, err := a.GatherMessages(ctx, false)
1304 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001305 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001306 return false, nil
1307 }
1308
1309 // Inject any auto-generated messages from quality checks
1310 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001311 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001312 }
1313
1314 // Handle cancellation by appending a message about it
1315 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001316 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001317 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001318 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001319 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1320 } else if err := a.convo.OverBudget(); err != nil {
1321 // Handle budget issues by appending a message about it
1322 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 -07001323 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001324 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1325 }
1326
1327 // Combine tool results with user messages
1328 results = append(results, msgs...)
1329
1330 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001331 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001332 resp, err := a.convo.SendMessage(llm.Message{
1333 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001334 Content: results,
1335 })
1336 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001337 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001338 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1339 return true, nil // Return true to continue the conversation, but with no response
1340 }
1341
Sean McCullough96b60dd2025-04-30 09:49:10 -07001342 // Transition back to processing LLM response
1343 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1344
Sean McCullough885a16a2025-04-30 02:49:25 +00001345 if cancelled {
1346 return false, nil
1347 }
1348
1349 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001350}
1351
1352func (a *Agent) overBudget(ctx context.Context) error {
1353 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001354 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001355 m := budgetMessage(err)
1356 m.Content = m.Content + "\n\nBudget reset."
1357 a.pushToOutbox(ctx, budgetMessage(err))
1358 a.convo.ResetBudget(a.originalBudget)
1359 return err
1360 }
1361 return nil
1362}
1363
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001364func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001365 // Collect all text content
1366 var allText strings.Builder
1367 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001368 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001369 if allText.Len() > 0 {
1370 allText.WriteString("\n\n")
1371 }
1372 allText.WriteString(content.Text)
1373 }
1374 }
1375 return allText.String()
1376}
1377
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001378func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001379 a.mu.Lock()
1380 defer a.mu.Unlock()
1381 return a.convo.CumulativeUsage()
1382}
1383
Earl Lee2e463fb2025-04-17 11:22:22 -07001384// Diff returns a unified diff of changes made since the agent was instantiated.
1385func (a *Agent) Diff(commit *string) (string, error) {
1386 if a.initialCommit == "" {
1387 return "", fmt.Errorf("no initial commit reference available")
1388 }
1389
1390 // Find the repository root
1391 ctx := context.Background()
1392
1393 // If a specific commit hash is provided, show just that commit's changes
1394 if commit != nil && *commit != "" {
1395 // Validate that the commit looks like a valid git SHA
1396 if !isValidGitSHA(*commit) {
1397 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1398 }
1399
1400 // Get the diff for just this commit
1401 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1402 cmd.Dir = a.repoRoot
1403 output, err := cmd.CombinedOutput()
1404 if err != nil {
1405 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1406 }
1407 return string(output), nil
1408 }
1409
1410 // Otherwise, get the diff between the initial commit and the current state using exec.Command
1411 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.initialCommit)
1412 cmd.Dir = a.repoRoot
1413 output, err := cmd.CombinedOutput()
1414 if err != nil {
1415 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1416 }
1417
1418 return string(output), nil
1419}
1420
1421// InitialCommit returns the Git commit hash that was saved when the agent was instantiated.
1422func (a *Agent) InitialCommit() string {
1423 return a.initialCommit
1424}
1425
1426// handleGitCommits() highlights new commits to the user. When running
1427// under docker, new HEADs are pushed to a branch according to the title.
1428func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1429 if a.repoRoot == "" {
1430 return nil, nil
1431 }
1432
1433 head, err := resolveRef(ctx, a.repoRoot, "HEAD")
1434 if err != nil {
1435 return nil, err
1436 }
1437 if head == a.lastHEAD {
1438 return nil, nil // nothing to do
1439 }
1440 defer func() {
1441 a.lastHEAD = head
1442 }()
1443
1444 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1445 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1446 // to the last 100 commits.
1447 var commits []*GitCommit
1448
1449 // Get commits since the initial commit
1450 // Format: <hash>\0<subject>\0<body>\0
1451 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1452 // Limit to 100 commits to avoid overwhelming the user
1453 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+a.initialCommit, head)
1454 cmd.Dir = a.repoRoot
1455 output, err := cmd.Output()
1456 if err != nil {
1457 return nil, fmt.Errorf("failed to get git log: %w", err)
1458 }
1459
1460 // Parse git log output and filter out already seen commits
1461 parsedCommits := parseGitLog(string(output))
1462
1463 var headCommit *GitCommit
1464
1465 // Filter out commits we've already seen
1466 for _, commit := range parsedCommits {
1467 if commit.Hash == head {
1468 headCommit = &commit
1469 }
1470
1471 // Skip if we've seen this commit before. If our head has changed, always include that.
1472 if a.seenCommits[commit.Hash] && commit.Hash != head {
1473 continue
1474 }
1475
1476 // Mark this commit as seen
1477 a.seenCommits[commit.Hash] = true
1478
1479 // Add to our list of new commits
1480 commits = append(commits, &commit)
1481 }
1482
1483 if a.gitRemoteAddr != "" {
1484 if headCommit == nil {
1485 // I think this can only happen if we have a bug or if there's a race.
1486 headCommit = &GitCommit{}
1487 headCommit.Hash = head
1488 headCommit.Subject = "unknown"
1489 commits = append(commits, headCommit)
1490 }
1491
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001492 branch := cmp.Or(a.branchName, "sketch/"+a.config.SessionID)
Earl Lee2e463fb2025-04-17 11:22:22 -07001493
1494 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1495 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1496 // then use push with lease to replace.
1497 cmd = exec.Command("git", "push", "--force", a.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1498 cmd.Dir = a.workingDir
1499 if out, err := cmd.CombinedOutput(); err != nil {
1500 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
1501 } else {
1502 headCommit.PushedBranch = branch
1503 }
1504 }
1505
1506 // If we found new commits, create a message
1507 if len(commits) > 0 {
1508 msg := AgentMessage{
1509 Type: CommitMessageType,
1510 Timestamp: time.Now(),
1511 Commits: commits,
1512 }
1513 a.pushToOutbox(ctx, msg)
1514 }
1515 return commits, nil
1516}
1517
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001518func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001519 return strings.Map(func(r rune) rune {
1520 // lowercase
1521 if r >= 'A' && r <= 'Z' {
1522 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001523 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001524 // replace spaces with dashes
1525 if r == ' ' {
1526 return '-'
1527 }
1528 // allow alphanumerics and dashes
1529 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1530 return r
1531 }
1532 return -1
1533 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001534}
1535
1536// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1537// and returns an array of GitCommit structs.
1538func parseGitLog(output string) []GitCommit {
1539 var commits []GitCommit
1540
1541 // No output means no commits
1542 if len(output) == 0 {
1543 return commits
1544 }
1545
1546 // Split by NULL byte
1547 parts := strings.Split(output, "\x00")
1548
1549 // Process in triplets (hash, subject, body)
1550 for i := 0; i < len(parts); i++ {
1551 // Skip empty parts
1552 if parts[i] == "" {
1553 continue
1554 }
1555
1556 // This should be a hash
1557 hash := strings.TrimSpace(parts[i])
1558
1559 // Make sure we have at least a subject part available
1560 if i+1 >= len(parts) {
1561 break // No more parts available
1562 }
1563
1564 // Get the subject
1565 subject := strings.TrimSpace(parts[i+1])
1566
1567 // Get the body if available
1568 body := ""
1569 if i+2 < len(parts) {
1570 body = strings.TrimSpace(parts[i+2])
1571 }
1572
1573 // Skip to the next triplet
1574 i += 2
1575
1576 commits = append(commits, GitCommit{
1577 Hash: hash,
1578 Subject: subject,
1579 Body: body,
1580 })
1581 }
1582
1583 return commits
1584}
1585
1586func repoRoot(ctx context.Context, dir string) (string, error) {
1587 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1588 stderr := new(strings.Builder)
1589 cmd.Stderr = stderr
1590 cmd.Dir = dir
1591 out, err := cmd.Output()
1592 if err != nil {
1593 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1594 }
1595 return strings.TrimSpace(string(out)), nil
1596}
1597
1598func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1599 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1600 stderr := new(strings.Builder)
1601 cmd.Stderr = stderr
1602 cmd.Dir = dir
1603 out, err := cmd.Output()
1604 if err != nil {
1605 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1606 }
1607 // TODO: validate that out is valid hex
1608 return strings.TrimSpace(string(out)), nil
1609}
1610
1611// isValidGitSHA validates if a string looks like a valid git SHA hash.
1612// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1613func isValidGitSHA(sha string) bool {
1614 // Git SHA must be a hexadecimal string with at least 4 characters
1615 if len(sha) < 4 || len(sha) > 40 {
1616 return false
1617 }
1618
1619 // Check if the string only contains hexadecimal characters
1620 for _, char := range sha {
1621 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1622 return false
1623 }
1624 }
1625
1626 return true
1627}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001628
1629// getGitOrigin returns the URL of the git remote 'origin' if it exists
1630func getGitOrigin(ctx context.Context, dir string) string {
1631 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1632 cmd.Dir = dir
1633 stderr := new(strings.Builder)
1634 cmd.Stderr = stderr
1635 out, err := cmd.Output()
1636 if err != nil {
1637 return ""
1638 }
1639 return strings.TrimSpace(string(out))
1640}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001641
1642func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1643 cmd := exec.CommandContext(ctx, "git", "stash")
1644 cmd.Dir = workingDir
1645 if out, err := cmd.CombinedOutput(); err != nil {
1646 return fmt.Errorf("git stash: %s: %v", out, err)
1647 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001648 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001649 cmd.Dir = workingDir
1650 if out, err := cmd.CombinedOutput(); err != nil {
1651 return fmt.Errorf("git fetch: %s: %w", out, err)
1652 }
1653 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1654 cmd.Dir = workingDir
1655 if out, err := cmd.CombinedOutput(); err != nil {
1656 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1657 }
1658 a.lastHEAD = revision
1659 a.initialCommit = revision
1660 return nil
1661}
1662
1663func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1664 a.mu.Lock()
1665 a.title = ""
1666 a.firstMessageIndex = len(a.history)
1667 a.convo = a.initConvo()
1668 gitReset := func() error {
1669 if a.config.InDocker && rev != "" {
1670 err := a.initGitRevision(ctx, a.workingDir, rev)
1671 if err != nil {
1672 return err
1673 }
1674 } else if !a.config.InDocker && rev != "" {
1675 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1676 }
1677 return nil
1678 }
1679 err := gitReset()
1680 a.mu.Unlock()
1681 if err != nil {
1682 a.pushToOutbox(a.config.Context, errorMessage(err))
1683 }
1684
1685 a.pushToOutbox(a.config.Context, AgentMessage{
1686 Type: AgentMessageType, Content: "Conversation restarted.",
1687 })
1688 if initialPrompt != "" {
1689 a.UserMessage(ctx, initialPrompt)
1690 }
1691 return nil
1692}
1693
1694func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1695 msg := `The user has requested a suggestion for a re-prompt.
1696
1697 Given the current conversation thus far, suggest a re-prompt that would
1698 capture the instructions and feedback so far, as well as any
1699 research or other information that would be helpful in implementing
1700 the task.
1701
1702 Reply with ONLY the reprompt text.
1703 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001704 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001705 // By doing this in a subconversation, the agent doesn't call tools (because
1706 // there aren't any), and there's not a concurrency risk with on-going other
1707 // outstanding conversations.
1708 convo := a.convo.SubConvoWithHistory()
1709 resp, err := convo.SendMessage(userMessage)
1710 if err != nil {
1711 a.pushToOutbox(ctx, errorMessage(err))
1712 return "", err
1713 }
1714 textContent := collectTextContent(resp)
1715 return textContent, nil
1716}
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001717
1718// systemPromptData contains the data used to render the system prompt template
1719type systemPromptData struct {
1720 EditPrompt string
1721 ClientGOOS string
1722 ClientGOARCH string
1723 WorkingDir string
1724 RepoRoot string
1725 InitialCommit string
1726}
1727
1728// renderSystemPrompt renders the system prompt template.
1729func (a *Agent) renderSystemPrompt() string {
1730 // Determine the appropriate edit prompt based on config
1731 var editPrompt string
1732 if a.config.UseAnthropicEdit {
1733 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."
1734 } else {
1735 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1736 }
1737
1738 data := systemPromptData{
1739 EditPrompt: editPrompt,
1740 ClientGOOS: a.config.ClientGOOS,
1741 ClientGOARCH: a.config.ClientGOARCH,
1742 WorkingDir: a.workingDir,
1743 RepoRoot: a.repoRoot,
1744 InitialCommit: a.initialCommit,
1745 }
1746
1747 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1748 if err != nil {
1749 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1750 }
1751 buf := new(strings.Builder)
1752 err = tmpl.Execute(buf, data)
1753 if err != nil {
1754 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1755 }
1756 return buf.String()
1757}