blob: 84e69111b4c3f2d753354114995cceb39531853c [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"
Pokey Rule7a113622025-05-12 10:58:45 +010014 "path/filepath"
Earl Lee2e463fb2025-04-17 11:22:22 -070015 "runtime/debug"
16 "slices"
17 "strings"
18 "sync"
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +000019 "text/template"
Earl Lee2e463fb2025-04-17 11:22:22 -070020 "time"
21
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"
Autoformatter4962f152025-05-06 17:24:20 +000025 "sketch.dev/claudetool/browse"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000026 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydera997be62025-05-07 22:52:46 +000027 "sketch.dev/claudetool/onstart"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070028 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070029 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070030 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070031)
32
33const (
34 userCancelMessage = "user requested agent to stop handling responses"
35)
36
Philip Zeyligerb7c58752025-05-01 10:10:17 -070037type MessageIterator interface {
38 // Next blocks until the next message is available. It may
39 // return nil if the underlying iterator context is done.
40 Next() *AgentMessage
41 Close()
42}
43
Earl Lee2e463fb2025-04-17 11:22:22 -070044type CodingAgent interface {
45 // Init initializes an agent inside a docker container.
46 Init(AgentInit) error
47
48 // Ready returns a channel closed after Init successfully called.
49 Ready() <-chan struct{}
50
51 // URL reports the HTTP URL of this agent.
52 URL() string
53
54 // UserMessage enqueues a message to the agent and returns immediately.
55 UserMessage(ctx context.Context, msg string)
56
Philip Zeyligerb7c58752025-05-01 10:10:17 -070057 // Returns an iterator that finishes when the context is done and
58 // starts with the given message index.
59 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070060
Philip Zeyligereab12de2025-05-14 02:35:53 +000061 // Returns an iterator that notifies of state transitions until the context is done.
62 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
63
Earl Lee2e463fb2025-04-17 11:22:22 -070064 // Loop begins the agent loop returns only when ctx is cancelled.
65 Loop(ctx context.Context)
66
Sean McCulloughedc88dc2025-04-30 02:55:01 +000067 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070068
69 CancelToolUse(toolUseID string, cause error) error
70
71 // Returns a subset of the agent's message history.
72 Messages(start int, end int) []AgentMessage
73
74 // Returns the current number of messages in the history
75 MessageCount() int
76
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070077 TotalUsage() conversation.CumulativeUsage
78 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070079
Earl Lee2e463fb2025-04-17 11:22:22 -070080 WorkingDir() string
81
82 // Diff returns a unified diff of changes made since the agent was instantiated.
83 // If commit is non-nil, it shows the diff for just that specific commit.
84 Diff(commit *string) (string, error)
85
Philip Zeyliger49edc922025-05-14 09:45:45 -070086 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
87 // starts out as the commit where sketch started, but a user can move it if need
88 // be, for example in the case of a rebase. It is stored as a git tag.
89 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070090
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000091 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
92 // (Typically, this is "sketch-base")
93 SketchGitBaseRef() string
94
Earl Lee2e463fb2025-04-17 11:22:22 -070095 // Title returns the current title of the conversation.
96 Title() string
97
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +000098 // BranchName returns the git branch name for the conversation.
99 BranchName() string
100
Earl Lee2e463fb2025-04-17 11:22:22 -0700101 // OS returns the operating system of the client.
102 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000103
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000104 // SessionID returns the unique session identifier.
105 SessionID() string
106
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000107 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
108 OutstandingLLMCallCount() int
109
110 // OutstandingToolCalls returns the names of outstanding tool calls.
111 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000112 OutsideOS() string
113 OutsideHostname() string
114 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000115 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000116 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
117 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700118
119 // RestartConversation resets the conversation history
120 RestartConversation(ctx context.Context, rev string, initialPrompt string) error
121 // SuggestReprompt suggests a re-prompt based on the current conversation.
122 SuggestReprompt(ctx context.Context) (string, error)
123 // IsInContainer returns true if the agent is running in a container
124 IsInContainer() bool
125 // FirstMessageIndex returns the index of the first message in the current conversation
126 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700127
128 CurrentStateName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700129}
130
131type CodingAgentMessageType string
132
133const (
134 UserMessageType CodingAgentMessageType = "user"
135 AgentMessageType CodingAgentMessageType = "agent"
136 ErrorMessageType CodingAgentMessageType = "error"
137 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
138 ToolUseMessageType CodingAgentMessageType = "tool"
139 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
140 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
141
142 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
143)
144
145type AgentMessage struct {
146 Type CodingAgentMessageType `json:"type"`
147 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
148 EndOfTurn bool `json:"end_of_turn"`
149
150 Content string `json:"content"`
151 ToolName string `json:"tool_name,omitempty"`
152 ToolInput string `json:"input,omitempty"`
153 ToolResult string `json:"tool_result,omitempty"`
154 ToolError bool `json:"tool_error,omitempty"`
155 ToolCallId string `json:"tool_call_id,omitempty"`
156
157 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
158 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
159
Sean McCulloughd9f13372025-04-21 15:08:49 -0700160 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
161 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
162
Earl Lee2e463fb2025-04-17 11:22:22 -0700163 // Commits is a list of git commits for a commit message
164 Commits []*GitCommit `json:"commits,omitempty"`
165
166 Timestamp time.Time `json:"timestamp"`
167 ConversationID string `json:"conversation_id"`
168 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700169 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700170
171 // Message timing information
172 StartTime *time.Time `json:"start_time,omitempty"`
173 EndTime *time.Time `json:"end_time,omitempty"`
174 Elapsed *time.Duration `json:"elapsed,omitempty"`
175
176 // Turn duration - the time taken for a complete agent turn
177 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
178
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000179 // HideOutput indicates that this message should not be rendered in the UI.
180 // This is useful for subconversations that generate output that shouldn't be shown to the user.
181 HideOutput bool `json:"hide_output,omitempty"`
182
Earl Lee2e463fb2025-04-17 11:22:22 -0700183 Idx int `json:"idx"`
184}
185
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000186// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700187func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700188 if convo == nil {
189 m.ConversationID = ""
190 m.ParentConversationID = nil
191 return
192 }
193 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000194 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700195 if convo.Parent != nil {
196 m.ParentConversationID = &convo.Parent.ID
197 }
198}
199
Earl Lee2e463fb2025-04-17 11:22:22 -0700200// GitCommit represents a single git commit for a commit message
201type GitCommit struct {
202 Hash string `json:"hash"` // Full commit hash
203 Subject string `json:"subject"` // Commit subject line
204 Body string `json:"body"` // Full commit message body
205 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
206}
207
208// ToolCall represents a single tool call within an agent message
209type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700210 Name string `json:"name"`
211 Input string `json:"input"`
212 ToolCallId string `json:"tool_call_id"`
213 ResultMessage *AgentMessage `json:"result_message,omitempty"`
214 Args string `json:"args,omitempty"`
215 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700216}
217
218func (a *AgentMessage) Attr() slog.Attr {
219 var attrs []any = []any{
220 slog.String("type", string(a.Type)),
221 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700222 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700223 if a.EndOfTurn {
224 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
225 }
226 if a.Content != "" {
227 attrs = append(attrs, slog.String("content", a.Content))
228 }
229 if a.ToolName != "" {
230 attrs = append(attrs, slog.String("tool_name", a.ToolName))
231 }
232 if a.ToolInput != "" {
233 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
234 }
235 if a.Elapsed != nil {
236 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
237 }
238 if a.TurnDuration != nil {
239 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
240 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700241 if len(a.ToolResult) > 0 {
242 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700243 }
244 if a.ToolError {
245 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
246 }
247 if len(a.ToolCalls) > 0 {
248 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
249 for i, tc := range a.ToolCalls {
250 toolCallAttrs = append(toolCallAttrs, slog.Group(
251 fmt.Sprintf("tool_call_%d", i),
252 slog.String("name", tc.Name),
253 slog.String("input", tc.Input),
254 ))
255 }
256 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
257 }
258 if a.ConversationID != "" {
259 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
260 }
261 if a.ParentConversationID != nil {
262 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
263 }
264 if a.Usage != nil && !a.Usage.IsZero() {
265 attrs = append(attrs, a.Usage.Attr())
266 }
267 // TODO: timestamp, convo ids, idx?
268 return slog.Group("agent_message", attrs...)
269}
270
271func errorMessage(err error) AgentMessage {
272 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
273 if os.Getenv(("DEBUG")) == "1" {
274 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
275 }
276
277 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
278}
279
280func budgetMessage(err error) AgentMessage {
281 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
282}
283
284// ConvoInterface defines the interface for conversation interactions
285type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700286 CumulativeUsage() conversation.CumulativeUsage
287 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700288 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700289 SendMessage(message llm.Message) (*llm.Response, error)
290 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700291 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000292 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700293 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700294 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700295 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700296}
297
Philip Zeyligerf2872992025-05-22 10:35:28 -0700298// AgentGitState holds the state necessary for pushing to a remote git repo
299// when HEAD changes. If gitRemoteAddr is set, then we push to sketch/
300// any time we notice we need to.
301type AgentGitState struct {
302 mu sync.Mutex // protects following
303 lastHEAD string // hash of the last HEAD that was pushed to the host
304 gitRemoteAddr string // HTTP URL of the host git repo
305 seenCommits map[string]bool // Track git commits we've already seen (by hash)
306 branchName string
307}
308
309func (ags *AgentGitState) SetBranchName(branchName string) {
310 ags.mu.Lock()
311 defer ags.mu.Unlock()
312 ags.branchName = branchName
313}
314
315func (ags *AgentGitState) BranchName() string {
316 ags.mu.Lock()
317 defer ags.mu.Unlock()
318 return ags.branchName
319}
320
Earl Lee2e463fb2025-04-17 11:22:22 -0700321type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700322 convo ConvoInterface
323 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700324 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700325 workingDir string
326 repoRoot string // workingDir may be a subdir of repoRoot
327 url string
328 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000329 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700330 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000331 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700332 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700333 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700334 title string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000335 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700336 // State machine to track agent state
337 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000338 // Outside information
339 outsideHostname string
340 outsideOS string
341 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000342 // URL of the git remote 'origin' if it exists
343 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700344
345 // Time when the current turn started (reset at the beginning of InnerLoop)
346 startOfTurn time.Time
347
348 // Inbox - for messages from the user to the agent.
349 // sent on by UserMessage
350 // . e.g. when user types into the chat textarea
351 // read from by GatherMessages
352 inbox chan string
353
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000354 // protects cancelTurn
355 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700356 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000357 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700358
359 // protects following
360 mu sync.Mutex
361
362 // Stores all messages for this agent
363 history []AgentMessage
364
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700365 // Iterators add themselves here when they're ready to be notified of new messages.
366 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700367
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000368 // Track outstanding LLM call IDs
369 outstandingLLMCalls map[string]struct{}
370
371 // Track outstanding tool calls by ID with their names
372 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700373}
374
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700375// NewIterator implements CodingAgent.
376func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
377 a.mu.Lock()
378 defer a.mu.Unlock()
379
380 return &MessageIteratorImpl{
381 agent: a,
382 ctx: ctx,
383 nextMessageIdx: nextMessageIdx,
384 ch: make(chan *AgentMessage, 100),
385 }
386}
387
388type MessageIteratorImpl struct {
389 agent *Agent
390 ctx context.Context
391 nextMessageIdx int
392 ch chan *AgentMessage
393 subscribed bool
394}
395
396func (m *MessageIteratorImpl) Close() {
397 m.agent.mu.Lock()
398 defer m.agent.mu.Unlock()
399 // Delete ourselves from the subscribers list
400 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
401 return x == m.ch
402 })
403 close(m.ch)
404}
405
406func (m *MessageIteratorImpl) Next() *AgentMessage {
407 // We avoid subscription at creation to let ourselves catch up to "current state"
408 // before subscribing.
409 if !m.subscribed {
410 m.agent.mu.Lock()
411 if m.nextMessageIdx < len(m.agent.history) {
412 msg := &m.agent.history[m.nextMessageIdx]
413 m.nextMessageIdx++
414 m.agent.mu.Unlock()
415 return msg
416 }
417 // The next message doesn't exist yet, so let's subscribe
418 m.agent.subscribers = append(m.agent.subscribers, m.ch)
419 m.subscribed = true
420 m.agent.mu.Unlock()
421 }
422
423 for {
424 select {
425 case <-m.ctx.Done():
426 m.agent.mu.Lock()
427 // Delete ourselves from the subscribers list
428 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
429 return x == m.ch
430 })
431 m.subscribed = false
432 m.agent.mu.Unlock()
433 return nil
434 case msg, ok := <-m.ch:
435 if !ok {
436 // Close may have been called
437 return nil
438 }
439 if msg.Idx == m.nextMessageIdx {
440 m.nextMessageIdx++
441 return msg
442 }
443 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
444 panic("out of order message")
445 }
446 }
447}
448
Sean McCulloughd9d45812025-04-30 16:53:41 -0700449// Assert that Agent satisfies the CodingAgent interface.
450var _ CodingAgent = &Agent{}
451
452// StateName implements CodingAgent.
453func (a *Agent) CurrentStateName() string {
454 if a.stateMachine == nil {
455 return ""
456 }
457 return a.stateMachine.currentState.String()
458}
459
Earl Lee2e463fb2025-04-17 11:22:22 -0700460func (a *Agent) URL() string { return a.url }
461
462// Title returns the current title of the conversation.
463// If no title has been set, returns an empty string.
464func (a *Agent) Title() string {
465 a.mu.Lock()
466 defer a.mu.Unlock()
467 return a.title
468}
469
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000470// BranchName returns the git branch name for the conversation.
471func (a *Agent) BranchName() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700472 return a.gitState.BranchName()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000473}
474
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000475// OutstandingLLMCallCount returns the number of outstanding LLM calls.
476func (a *Agent) OutstandingLLMCallCount() int {
477 a.mu.Lock()
478 defer a.mu.Unlock()
479 return len(a.outstandingLLMCalls)
480}
481
482// OutstandingToolCalls returns the names of outstanding tool calls.
483func (a *Agent) OutstandingToolCalls() []string {
484 a.mu.Lock()
485 defer a.mu.Unlock()
486
487 tools := make([]string, 0, len(a.outstandingToolCalls))
488 for _, toolName := range a.outstandingToolCalls {
489 tools = append(tools, toolName)
490 }
491 return tools
492}
493
Earl Lee2e463fb2025-04-17 11:22:22 -0700494// OS returns the operating system of the client.
495func (a *Agent) OS() string {
496 return a.config.ClientGOOS
497}
498
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000499func (a *Agent) SessionID() string {
500 return a.config.SessionID
501}
502
Philip Zeyliger18532b22025-04-23 21:11:46 +0000503// OutsideOS returns the operating system of the outside system.
504func (a *Agent) OutsideOS() string {
505 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000506}
507
Philip Zeyliger18532b22025-04-23 21:11:46 +0000508// OutsideHostname returns the hostname of the outside system.
509func (a *Agent) OutsideHostname() string {
510 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000511}
512
Philip Zeyliger18532b22025-04-23 21:11:46 +0000513// OutsideWorkingDir returns the working directory on the outside system.
514func (a *Agent) OutsideWorkingDir() string {
515 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000516}
517
518// GitOrigin returns the URL of the git remote 'origin' if it exists.
519func (a *Agent) GitOrigin() string {
520 return a.gitOrigin
521}
522
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000523func (a *Agent) OpenBrowser(url string) {
524 if !a.IsInContainer() {
525 browser.Open(url)
526 return
527 }
528 // We're in Docker, need to send a request to the Git server
529 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700530 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000531 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700532 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000533 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700534 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000535 return
536 }
537 defer resp.Body.Close()
538 if resp.StatusCode == http.StatusOK {
539 return
540 }
541 body, _ := io.ReadAll(resp.Body)
542 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
543}
544
Sean McCullough96b60dd2025-04-30 09:49:10 -0700545// CurrentState returns the current state of the agent's state machine.
546func (a *Agent) CurrentState() State {
547 return a.stateMachine.CurrentState()
548}
549
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700550func (a *Agent) IsInContainer() bool {
551 return a.config.InDocker
552}
553
554func (a *Agent) FirstMessageIndex() int {
555 a.mu.Lock()
556 defer a.mu.Unlock()
557 return a.firstMessageIndex
558}
559
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000560// SetTitle sets the title of the conversation.
561func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700562 a.mu.Lock()
563 defer a.mu.Unlock()
564 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000565}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700566
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000567// SetBranch sets the branch name of the conversation.
568func (a *Agent) SetBranch(branchName string) {
569 a.mu.Lock()
570 defer a.mu.Unlock()
Philip Zeyligerf2872992025-05-22 10:35:28 -0700571 a.gitState.SetBranchName(branchName)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000572 convo, ok := a.convo.(*conversation.Convo)
573 if ok {
574 convo.ExtraData["branch"] = branchName
575 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700576}
577
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000578// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700579func (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 +0000580 // Track the tool call
581 a.mu.Lock()
582 a.outstandingToolCalls[id] = toolName
583 a.mu.Unlock()
584}
585
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700586// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
587// If there's only one element in the array and it's a text type, it returns that text directly.
588// It also processes nested ToolResult arrays recursively.
589func contentToString(contents []llm.Content) string {
590 if len(contents) == 0 {
591 return ""
592 }
593
594 // If there's only one element and it's a text type, return it directly
595 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
596 return contents[0].Text
597 }
598
599 // Otherwise, concatenate all text content
600 var result strings.Builder
601 for _, content := range contents {
602 if content.Type == llm.ContentTypeText {
603 result.WriteString(content.Text)
604 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
605 // Recursively process nested tool results
606 result.WriteString(contentToString(content.ToolResult))
607 }
608 }
609
610 return result.String()
611}
612
Earl Lee2e463fb2025-04-17 11:22:22 -0700613// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700614func (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 +0000615 // Remove the tool call from outstanding calls
616 a.mu.Lock()
617 delete(a.outstandingToolCalls, toolID)
618 a.mu.Unlock()
619
Earl Lee2e463fb2025-04-17 11:22:22 -0700620 m := AgentMessage{
621 Type: ToolUseMessageType,
622 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700623 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700624 ToolError: content.ToolError,
625 ToolName: toolName,
626 ToolInput: string(toolInput),
627 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700628 StartTime: content.ToolUseStartTime,
629 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700630 }
631
632 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700633 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
634 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700635 m.Elapsed = &elapsed
636 }
637
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700638 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700639 a.pushToOutbox(ctx, m)
640}
641
642// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700643func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000644 a.mu.Lock()
645 defer a.mu.Unlock()
646 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700647 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
648}
649
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700650// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700651// that need to be displayed (as well as tool calls that we send along when
652// they're done). (It would be reasonable to also mention tool calls when they're
653// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700654func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000655 // Remove the LLM call from outstanding calls
656 a.mu.Lock()
657 delete(a.outstandingLLMCalls, id)
658 a.mu.Unlock()
659
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700660 if resp == nil {
661 // LLM API call failed
662 m := AgentMessage{
663 Type: ErrorMessageType,
664 Content: "API call failed, type 'continue' to try again",
665 }
666 m.SetConvo(convo)
667 a.pushToOutbox(ctx, m)
668 return
669 }
670
Earl Lee2e463fb2025-04-17 11:22:22 -0700671 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700672 if convo.Parent == nil { // subconvos never end the turn
673 switch resp.StopReason {
674 case llm.StopReasonToolUse:
675 // Check whether any of the tool calls are for tools that should end the turn
676 ToolSearch:
677 for _, part := range resp.Content {
678 if part.Type != llm.ContentTypeToolUse {
679 continue
680 }
Sean McCullough021557a2025-05-05 23:20:53 +0000681 // Find the tool by name
682 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700683 if tool.Name == part.ToolName {
684 endOfTurn = tool.EndsTurn
685 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000686 }
687 }
Sean McCullough021557a2025-05-05 23:20:53 +0000688 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700689 default:
690 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000691 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700692 }
693 m := AgentMessage{
694 Type: AgentMessageType,
695 Content: collectTextContent(resp),
696 EndOfTurn: endOfTurn,
697 Usage: &resp.Usage,
698 StartTime: resp.StartTime,
699 EndTime: resp.EndTime,
700 }
701
702 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700703 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700704 var toolCalls []ToolCall
705 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700706 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700707 toolCalls = append(toolCalls, ToolCall{
708 Name: part.ToolName,
709 Input: string(part.ToolInput),
710 ToolCallId: part.ID,
711 })
712 }
713 }
714 m.ToolCalls = toolCalls
715 }
716
717 // Calculate the elapsed time if both start and end times are set
718 if resp.StartTime != nil && resp.EndTime != nil {
719 elapsed := resp.EndTime.Sub(*resp.StartTime)
720 m.Elapsed = &elapsed
721 }
722
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700723 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700724 a.pushToOutbox(ctx, m)
725}
726
727// WorkingDir implements CodingAgent.
728func (a *Agent) WorkingDir() string {
729 return a.workingDir
730}
731
732// MessageCount implements CodingAgent.
733func (a *Agent) MessageCount() int {
734 a.mu.Lock()
735 defer a.mu.Unlock()
736 return len(a.history)
737}
738
739// Messages implements CodingAgent.
740func (a *Agent) Messages(start int, end int) []AgentMessage {
741 a.mu.Lock()
742 defer a.mu.Unlock()
743 return slices.Clone(a.history[start:end])
744}
745
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700746func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700747 return a.originalBudget
748}
749
750// AgentConfig contains configuration for creating a new Agent.
751type AgentConfig struct {
752 Context context.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700753 Service llm.Service
754 Budget conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -0700755 GitUsername string
756 GitEmail string
757 SessionID string
758 ClientGOOS string
759 ClientGOARCH string
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700760 InDocker bool
Earl Lee2e463fb2025-04-17 11:22:22 -0700761 UseAnthropicEdit bool
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000762 OneShot bool
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700763 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000764 // Outside information
765 OutsideHostname string
766 OutsideOS string
767 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700768
769 // Outtie's HTTP to, e.g., open a browser
770 OutsideHTTP string
771 // Outtie's Git server
772 GitRemoteAddr string
773 // Commit to checkout from Outtie
774 Commit string
Earl Lee2e463fb2025-04-17 11:22:22 -0700775}
776
777// NewAgent creates a new Agent.
778// It is not usable until Init() is called.
779func NewAgent(config AgentConfig) *Agent {
780 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -0700781 config: config,
782 ready: make(chan struct{}),
783 inbox: make(chan string, 100),
784 subscribers: make([]chan *AgentMessage, 0),
785 startedAt: time.Now(),
786 originalBudget: config.Budget,
787 gitState: AgentGitState{
788 seenCommits: make(map[string]bool),
789 gitRemoteAddr: config.GitRemoteAddr,
790 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000791 outsideHostname: config.OutsideHostname,
792 outsideOS: config.OutsideOS,
793 outsideWorkingDir: config.OutsideWorkingDir,
794 outstandingLLMCalls: make(map[string]struct{}),
795 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700796 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700797 workingDir: config.WorkingDir,
798 outsideHTTP: config.OutsideHTTP,
Earl Lee2e463fb2025-04-17 11:22:22 -0700799 }
800 return agent
801}
802
803type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700804 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -0700805
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700806 InDocker bool
807 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -0700808}
809
810func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700811 if a.convo != nil {
812 return fmt.Errorf("Agent.Init: already initialized")
813 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700814 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -0700815 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700816
Philip Zeyligerf2872992025-05-22 10:35:28 -0700817 // If a remote git addr was specified, we configure the remote
818 if a.gitState.gitRemoteAddr != "" {
819 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
820 cmd := exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", a.gitState.gitRemoteAddr)
821 cmd.Dir = a.workingDir
822 if out, err := cmd.CombinedOutput(); err != nil {
823 return fmt.Errorf("git remote add: %s: %v", out, err)
824 }
825 // sketch-host is a git repo hosted by "outtie sketch". When it notices a 'git fetch',
826 // it runs "git fetch" underneath the covers to get its latest commits. By configuring
827 // an additional remote.sketch-host.fetch, we make "origin/main" on innie sketch look like
828 // origin/main on outtie sketch, which should make it easier to rebase.
829 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.sketch-host.fetch",
830 "+refs/heads/feature/*:refs/remotes/origin/feature/*")
831 cmd.Dir = a.workingDir
832 if out, err := cmd.CombinedOutput(); err != nil {
833 return fmt.Errorf("git config --add: %s: %v", out, err)
834 }
835 }
836
837 // If a commit was specified, we fetch and reset to it.
838 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -0700839 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
840
Earl Lee2e463fb2025-04-17 11:22:22 -0700841 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700842 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -0700843 if out, err := cmd.CombinedOutput(); err != nil {
844 return fmt.Errorf("git stash: %s: %v", out, err)
845 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000846 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700847 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -0700848 if out, err := cmd.CombinedOutput(); err != nil {
849 return fmt.Errorf("git fetch: %s: %w", out, err)
850 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700851 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
852 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100853 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
854 // Remove git hooks if they exist and retry
855 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700856 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +0100857 if _, statErr := os.Stat(hookPath); statErr == nil {
858 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
859 slog.String("error", err.Error()),
860 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700861 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +0100862 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
863 }
864
865 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700866 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
867 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100868 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700869 return fmt.Errorf("git checkout %s failed even after removing hooks: %s: %w", a.config.Commit, retryOut, retryErr)
Pokey Rule7a113622025-05-12 10:58:45 +0100870 }
871 } else {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700872 return fmt.Errorf("git checkout %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +0100873 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700874 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700875 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700876
877 if ini.HostAddr != "" {
878 a.url = "http://" + ini.HostAddr
879 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700880
881 if !ini.NoGit {
882 repoRoot, err := repoRoot(ctx, a.workingDir)
883 if err != nil {
884 return fmt.Errorf("repoRoot: %w", err)
885 }
886 a.repoRoot = repoRoot
887
Earl Lee2e463fb2025-04-17 11:22:22 -0700888 if err != nil {
889 return fmt.Errorf("resolveRef: %w", err)
890 }
Philip Zeyliger49edc922025-05-14 09:45:45 -0700891
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700892 if err := setupGitHooks(a.workingDir); err != nil {
893 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
894 }
895
Philip Zeyliger49edc922025-05-14 09:45:45 -0700896 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
897 cmd.Dir = repoRoot
898 if out, err := cmd.CombinedOutput(); err != nil {
899 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
900 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700901
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +0000902 slog.Info("running codebase analysis")
903 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
904 if err != nil {
905 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000906 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +0000907 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000908
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +0000909 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -0700910 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000911 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700912 }
913 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000914
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700915 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700916 }
Philip Zeyligerf2872992025-05-22 10:35:28 -0700917 a.gitState.lastHEAD = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -0700918 a.convo = a.initConvo()
919 close(a.ready)
920 return nil
921}
922
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700923//go:embed agent_system_prompt.txt
924var agentSystemPrompt string
925
Earl Lee2e463fb2025-04-17 11:22:22 -0700926// initConvo initializes the conversation.
927// It must not be called until all agent fields are initialized,
928// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700929func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700930 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700931 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700932 convo.PromptCaching = true
933 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000934 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000935 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -0700936
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000937 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
938 bashPermissionCheck := func(command string) error {
939 // Check if branch name is set
940 a.mu.Lock()
Philip Zeyligerf2872992025-05-22 10:35:28 -0700941 branchSet := a.gitState.BranchName() != ""
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000942 a.mu.Unlock()
943
944 // If branch is set, all commands are allowed
945 if branchSet {
946 return nil
947 }
948
949 // If branch is not set, check if this is a git commit command
950 willCommit, err := bashkit.WillRunGitCommit(command)
951 if err != nil {
952 // If there's an error checking, we should allow the command to proceed
953 return nil
954 }
955
956 // If it's a git commit and branch is not set, return an error
957 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000958 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000959 }
960
961 return nil
962 }
963
964 // Create a custom bash tool with the permission check
965 bashTool := claudetool.NewBashTool(bashPermissionCheck)
966
Earl Lee2e463fb2025-04-17 11:22:22 -0700967 // Register all tools with the conversation
968 // When adding, removing, or modifying tools here, double-check that the termui tool display
969 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000970
971 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -0700972 _, supportsScreenshots := a.config.Service.(*ant.Service)
973 var bTools []*llm.Tool
974 var browserCleanup func()
975
976 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
977 // Add cleanup function to context cancel
978 go func() {
979 <-a.config.Context.Done()
980 browserCleanup()
981 }()
982 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000983
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700984 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000985 bashTool, claudetool.Keyword,
Josh Bleecher Snyder93202652025-05-08 02:05:57 +0000986 claudetool.Think, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -0700987 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000988 }
989
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +0000990 // One-shot mode is non-interactive, multiple choice requires human response
991 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -0700992 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -0700993 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +0000994
995 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -0700996 if a.config.UseAnthropicEdit {
997 convo.Tools = append(convo.Tools, claudetool.AnthropicEditTool)
998 } else {
999 convo.Tools = append(convo.Tools, claudetool.Patch)
1000 }
1001 convo.Listener = a
1002 return convo
1003}
1004
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001005var multipleChoiceTool = &llm.Tool{
1006 Name: "multiplechoice",
1007 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.",
1008 EndsTurn: true,
1009 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001010 "type": "object",
1011 "description": "The question and a list of answers you would expect the user to choose from.",
1012 "properties": {
1013 "question": {
1014 "type": "string",
1015 "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?'"
1016 },
1017 "responseOptions": {
1018 "type": "array",
1019 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1020 "items": {
1021 "type": "object",
1022 "properties": {
1023 "caption": {
1024 "type": "string",
1025 "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'"
1026 },
1027 "responseText": {
1028 "type": "string",
1029 "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'"
1030 }
1031 },
1032 "required": ["caption", "responseText"]
1033 }
1034 }
1035 },
1036 "required": ["question", "responseOptions"]
1037}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001038 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1039 // The Run logic for "multiplechoice" tool is a no-op on the server.
1040 // The UI will present a list of options for the user to select from,
1041 // and that's it as far as "executing" the tool_use goes.
1042 // When the user *does* select one of the presented options, that
1043 // responseText gets sent as a chat message on behalf of the user.
1044 return llm.TextContent("end your turn and wait for the user to respond"), nil
1045 },
Sean McCullough485afc62025-04-28 14:28:39 -07001046}
1047
1048type MultipleChoiceOption struct {
1049 Caption string `json:"caption"`
1050 ResponseText string `json:"responseText"`
1051}
1052
1053type MultipleChoiceParams struct {
1054 Question string `json:"question"`
1055 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1056}
1057
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001058// branchExists reports whether branchName exists, either locally or in well-known remotes.
1059func branchExists(dir, branchName string) bool {
1060 refs := []string{
1061 "refs/heads/",
1062 "refs/remotes/origin/",
1063 "refs/remotes/sketch-host/",
1064 }
1065 for _, ref := range refs {
1066 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1067 cmd.Dir = dir
1068 if cmd.Run() == nil { // exit code 0 means branch exists
1069 return true
1070 }
1071 }
1072 return false
1073}
1074
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001075func (a *Agent) titleTool() *llm.Tool {
1076 description := `Sets the conversation title.`
1077 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001078 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001079 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001080 InputSchema: json.RawMessage(`{
1081 "type": "object",
1082 "properties": {
1083 "title": {
1084 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001085 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001086 }
1087 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001088 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001089}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001090 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001091 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001092 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001093 }
1094 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001095 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001096 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001097
1098 // We don't allow changing the title once set to be consistent with the previous behavior
1099 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001100 t := a.Title()
1101 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001102 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001103 }
1104
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001105 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001106 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001107 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001108
1109 a.SetTitle(params.Title)
1110 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001111 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001112 },
1113 }
1114 return titleTool
1115}
1116
1117func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001118 description := `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.`
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001119 preCommit := &llm.Tool{
1120 Name: "precommit",
1121 Description: description,
1122 InputSchema: json.RawMessage(`{
1123 "type": "object",
1124 "properties": {
1125 "branch_name": {
1126 "type": "string",
1127 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1128 }
1129 },
1130 "required": ["branch_name"]
1131}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001132 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001133 var params struct {
1134 BranchName string `json:"branch_name"`
1135 }
1136 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001137 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001138 }
1139
1140 b := a.BranchName()
1141 if b != "" {
Josh Bleecher Snyder44d1f1a2025-05-12 19:18:32 -07001142 return nil, fmt.Errorf("branch already set to %s; do not create a new branch", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001143 }
1144
1145 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001146 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001147 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001148 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001149 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001150 }
1151 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001152 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001153 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001154 }
1155
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001156 a.SetBranch(branchName)
Josh Bleecher Snyderf7bebdd2025-05-14 15:22:24 -07001157 response := fmt.Sprintf("switched to branch sketch/%q - DO NOT change branches unless explicitly requested", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001158
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001159 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1160 if err != nil {
1161 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1162 }
1163 if len(styleHint) > 0 {
1164 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001165 }
1166
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001167 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001168 },
1169 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001170 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001171}
1172
1173func (a *Agent) Ready() <-chan struct{} {
1174 return a.ready
1175}
1176
1177func (a *Agent) UserMessage(ctx context.Context, msg string) {
1178 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1179 a.inbox <- msg
1180}
1181
Earl Lee2e463fb2025-04-17 11:22:22 -07001182func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1183 return a.convo.CancelToolUse(toolUseID, cause)
1184}
1185
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001186func (a *Agent) CancelTurn(cause error) {
1187 a.cancelTurnMu.Lock()
1188 defer a.cancelTurnMu.Unlock()
1189 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001190 // Force state transition to cancelled state
1191 ctx := a.config.Context
1192 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001193 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001194 }
1195}
1196
1197func (a *Agent) Loop(ctxOuter context.Context) {
1198 for {
1199 select {
1200 case <-ctxOuter.Done():
1201 return
1202 default:
1203 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001204 a.cancelTurnMu.Lock()
1205 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001206 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001207 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001208 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001209 a.cancelTurn = cancel
1210 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001211 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1212 if err != nil {
1213 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1214 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001215 cancel(nil)
1216 }
1217 }
1218}
1219
1220func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1221 if m.Timestamp.IsZero() {
1222 m.Timestamp = time.Now()
1223 }
1224
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001225 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1226 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1227 m.Content = m.ToolResult
1228 }
1229
Earl Lee2e463fb2025-04-17 11:22:22 -07001230 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1231 if m.EndOfTurn && m.Type == AgentMessageType {
1232 turnDuration := time.Since(a.startOfTurn)
1233 m.TurnDuration = &turnDuration
1234 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1235 }
1236
Earl Lee2e463fb2025-04-17 11:22:22 -07001237 a.mu.Lock()
1238 defer a.mu.Unlock()
1239 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001240 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001241 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001242
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001243 // Notify all subscribers
1244 for _, ch := range a.subscribers {
1245 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001246 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001247}
1248
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001249func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1250 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001251 if block {
1252 select {
1253 case <-ctx.Done():
1254 return m, ctx.Err()
1255 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001256 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001257 }
1258 }
1259 for {
1260 select {
1261 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001262 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001263 default:
1264 return m, nil
1265 }
1266 }
1267}
1268
Sean McCullough885a16a2025-04-30 02:49:25 +00001269// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001270func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001271 // Reset the start of turn time
1272 a.startOfTurn = time.Now()
1273
Sean McCullough96b60dd2025-04-30 09:49:10 -07001274 // Transition to waiting for user input state
1275 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1276
Sean McCullough885a16a2025-04-30 02:49:25 +00001277 // Process initial user message
1278 initialResp, err := a.processUserMessage(ctx)
1279 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001280 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001281 return err
1282 }
1283
1284 // Handle edge case where both initialResp and err are nil
1285 if initialResp == nil {
1286 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001287 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1288
Sean McCullough9f4b8082025-04-30 17:34:07 +00001289 a.pushToOutbox(ctx, errorMessage(err))
1290 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001291 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001292
Earl Lee2e463fb2025-04-17 11:22:22 -07001293 // We do this as we go, but let's also do it at the end of the turn
1294 defer func() {
1295 if _, err := a.handleGitCommits(ctx); err != nil {
1296 // Just log the error, don't stop execution
1297 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1298 }
1299 }()
1300
Sean McCullougha1e0e492025-05-01 10:51:08 -07001301 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001302 resp := initialResp
1303 for {
1304 // Check if we are over budget
1305 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001306 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001307 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001308 }
1309
1310 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001311 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001312 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001313 break
1314 }
1315
Sean McCullough96b60dd2025-04-30 09:49:10 -07001316 // Transition to tool use requested state
1317 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1318
Sean McCullough885a16a2025-04-30 02:49:25 +00001319 // Handle tool execution
1320 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1321 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001322 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001323 }
1324
Sean McCullougha1e0e492025-05-01 10:51:08 -07001325 if toolResp == nil {
1326 return fmt.Errorf("cannot continue conversation with a nil tool response")
1327 }
1328
Sean McCullough885a16a2025-04-30 02:49:25 +00001329 // Set the response for the next iteration
1330 resp = toolResp
1331 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001332
1333 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001334}
1335
1336// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001337func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001338 // Wait for at least one message from the user
1339 msgs, err := a.GatherMessages(ctx, true)
1340 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001341 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001342 return nil, err
1343 }
1344
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001345 userMessage := llm.Message{
1346 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001347 Content: msgs,
1348 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001349
Sean McCullough96b60dd2025-04-30 09:49:10 -07001350 // Transition to sending to LLM state
1351 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1352
Sean McCullough885a16a2025-04-30 02:49:25 +00001353 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001354 resp, err := a.convo.SendMessage(userMessage)
1355 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001356 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001357 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001358 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001359 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001360
Sean McCullough96b60dd2025-04-30 09:49:10 -07001361 // Transition to processing LLM response state
1362 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1363
Sean McCullough885a16a2025-04-30 02:49:25 +00001364 return resp, nil
1365}
1366
1367// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001368func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1369 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001370 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001371 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001372
Sean McCullough96b60dd2025-04-30 09:49:10 -07001373 // Transition to checking for cancellation state
1374 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1375
Sean McCullough885a16a2025-04-30 02:49:25 +00001376 // Check if the operation was cancelled by the user
1377 select {
1378 case <-ctx.Done():
1379 // Don't actually run any of the tools, but rather build a response
1380 // for each tool_use message letting the LLM know that user canceled it.
1381 var err error
1382 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001383 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001384 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001385 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001386 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001387 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001388 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001389 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001390 // Transition to running tool state
1391 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1392
Sean McCullough885a16a2025-04-30 02:49:25 +00001393 // Add working directory to context for tool execution
1394 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
1395
1396 // Execute the tools
1397 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001398 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001399 if ctx.Err() != nil { // e.g. the user canceled the operation
1400 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001401 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001402 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001403 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001404 a.pushToOutbox(ctx, errorMessage(err))
1405 }
1406 }
1407
1408 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001409 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001410 autoqualityMessages := a.processGitChanges(ctx)
1411
1412 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001413 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001414 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001415 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001416 return false, nil
1417 }
1418
1419 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001420 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1421 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001422}
1423
1424// processGitChanges checks for new git commits and runs autoformatters if needed
1425func (a *Agent) processGitChanges(ctx context.Context) []string {
1426 // Check for git commits after tool execution
1427 newCommits, err := a.handleGitCommits(ctx)
1428 if err != nil {
1429 // Just log the error, don't stop execution
1430 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1431 return nil
1432 }
1433
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001434 // Run mechanical checks if there was exactly one new commit.
1435 if len(newCommits) != 1 {
1436 return nil
1437 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001438 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001439 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1440 msg := a.codereview.RunMechanicalChecks(ctx)
1441 if msg != "" {
1442 a.pushToOutbox(ctx, AgentMessage{
1443 Type: AutoMessageType,
1444 Content: msg,
1445 Timestamp: time.Now(),
1446 })
1447 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001448 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001449
1450 return autoqualityMessages
1451}
1452
1453// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001454func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001455 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001456 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001457 msgs, err := a.GatherMessages(ctx, false)
1458 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001459 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001460 return false, nil
1461 }
1462
1463 // Inject any auto-generated messages from quality checks
1464 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001465 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001466 }
1467
1468 // Handle cancellation by appending a message about it
1469 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001470 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001471 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001472 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001473 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1474 } else if err := a.convo.OverBudget(); err != nil {
1475 // Handle budget issues by appending a message about it
1476 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 -07001477 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001478 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1479 }
1480
1481 // Combine tool results with user messages
1482 results = append(results, msgs...)
1483
1484 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001485 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001486 resp, err := a.convo.SendMessage(llm.Message{
1487 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001488 Content: results,
1489 })
1490 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001491 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001492 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1493 return true, nil // Return true to continue the conversation, but with no response
1494 }
1495
Sean McCullough96b60dd2025-04-30 09:49:10 -07001496 // Transition back to processing LLM response
1497 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1498
Sean McCullough885a16a2025-04-30 02:49:25 +00001499 if cancelled {
1500 return false, nil
1501 }
1502
1503 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001504}
1505
1506func (a *Agent) overBudget(ctx context.Context) error {
1507 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001508 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001509 m := budgetMessage(err)
1510 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001511 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001512 a.convo.ResetBudget(a.originalBudget)
1513 return err
1514 }
1515 return nil
1516}
1517
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001518func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001519 // Collect all text content
1520 var allText strings.Builder
1521 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001522 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001523 if allText.Len() > 0 {
1524 allText.WriteString("\n\n")
1525 }
1526 allText.WriteString(content.Text)
1527 }
1528 }
1529 return allText.String()
1530}
1531
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001532func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001533 a.mu.Lock()
1534 defer a.mu.Unlock()
1535 return a.convo.CumulativeUsage()
1536}
1537
Earl Lee2e463fb2025-04-17 11:22:22 -07001538// Diff returns a unified diff of changes made since the agent was instantiated.
1539func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001540 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001541 return "", fmt.Errorf("no initial commit reference available")
1542 }
1543
1544 // Find the repository root
1545 ctx := context.Background()
1546
1547 // If a specific commit hash is provided, show just that commit's changes
1548 if commit != nil && *commit != "" {
1549 // Validate that the commit looks like a valid git SHA
1550 if !isValidGitSHA(*commit) {
1551 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1552 }
1553
1554 // Get the diff for just this commit
1555 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1556 cmd.Dir = a.repoRoot
1557 output, err := cmd.CombinedOutput()
1558 if err != nil {
1559 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1560 }
1561 return string(output), nil
1562 }
1563
1564 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001565 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001566 cmd.Dir = a.repoRoot
1567 output, err := cmd.CombinedOutput()
1568 if err != nil {
1569 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1570 }
1571
1572 return string(output), nil
1573}
1574
Philip Zeyliger49edc922025-05-14 09:45:45 -07001575// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1576// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1577func (a *Agent) SketchGitBaseRef() string {
1578 if a.IsInContainer() {
1579 return "sketch-base"
1580 } else {
1581 return "sketch-base-" + a.SessionID()
1582 }
1583}
1584
1585// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1586func (a *Agent) SketchGitBase() string {
1587 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1588 cmd.Dir = a.repoRoot
1589 output, err := cmd.CombinedOutput()
1590 if err != nil {
1591 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1592 return "HEAD"
1593 }
1594 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001595}
1596
Pokey Rule7a113622025-05-12 10:58:45 +01001597// removeGitHooks removes the Git hooks directory from the repository
1598func removeGitHooks(_ context.Context, repoPath string) error {
1599 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1600
1601 // Check if hooks directory exists
1602 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1603 // Directory doesn't exist, nothing to do
1604 return nil
1605 }
1606
1607 // Remove the hooks directory
1608 err := os.RemoveAll(hooksDir)
1609 if err != nil {
1610 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1611 }
1612
1613 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001614 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001615 if err != nil {
1616 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1617 }
1618
1619 return nil
1620}
1621
Philip Zeyligerf2872992025-05-22 10:35:28 -07001622func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1623 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef())
1624 for _, msg := range msgs {
1625 a.pushToOutbox(ctx, msg)
1626 }
1627 return commits, error
1628}
1629
Earl Lee2e463fb2025-04-17 11:22:22 -07001630// handleGitCommits() highlights new commits to the user. When running
1631// under docker, new HEADs are pushed to a branch according to the title.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001632func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string) ([]AgentMessage, []*GitCommit, error) {
1633 ags.mu.Lock()
1634 defer ags.mu.Unlock()
1635
1636 msgs := []AgentMessage{}
1637 if repoRoot == "" {
1638 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001639 }
1640
Philip Zeyligerf2872992025-05-22 10:35:28 -07001641 head, err := resolveRef(ctx, repoRoot, "HEAD")
Earl Lee2e463fb2025-04-17 11:22:22 -07001642 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001643 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001644 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001645 if head == ags.lastHEAD {
1646 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001647 }
1648 defer func() {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001649 ags.lastHEAD = head
Earl Lee2e463fb2025-04-17 11:22:22 -07001650 }()
1651
1652 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1653 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1654 // to the last 100 commits.
1655 var commits []*GitCommit
1656
1657 // Get commits since the initial commit
1658 // Format: <hash>\0<subject>\0<body>\0
1659 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1660 // Limit to 100 commits to avoid overwhelming the user
Philip Zeyligerf2872992025-05-22 10:35:28 -07001661 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, head)
1662 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001663 output, err := cmd.Output()
1664 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001665 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001666 }
1667
1668 // Parse git log output and filter out already seen commits
1669 parsedCommits := parseGitLog(string(output))
1670
1671 var headCommit *GitCommit
1672
1673 // Filter out commits we've already seen
1674 for _, commit := range parsedCommits {
1675 if commit.Hash == head {
1676 headCommit = &commit
1677 }
1678
1679 // Skip if we've seen this commit before. If our head has changed, always include that.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001680 if ags.seenCommits[commit.Hash] && commit.Hash != head {
Earl Lee2e463fb2025-04-17 11:22:22 -07001681 continue
1682 }
1683
1684 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001685 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001686
1687 // Add to our list of new commits
1688 commits = append(commits, &commit)
1689 }
1690
Philip Zeyligerf2872992025-05-22 10:35:28 -07001691 if ags.gitRemoteAddr != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001692 if headCommit == nil {
1693 // I think this can only happen if we have a bug or if there's a race.
1694 headCommit = &GitCommit{}
1695 headCommit.Hash = head
1696 headCommit.Subject = "unknown"
1697 commits = append(commits, headCommit)
1698 }
1699
Philip Zeyligerf2872992025-05-22 10:35:28 -07001700 originalBranch := cmp.Or(ags.branchName, "sketch/"+sessionID)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001701 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001702
1703 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1704 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1705 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001706
1707 // Try up to 10 times with different branch names if the branch is checked out on the remote
1708 var out []byte
1709 var err error
1710 for retries := range 10 {
1711 if retries > 0 {
1712 // Add a numeric suffix to the branch name
1713 branch = fmt.Sprintf("%s%d", originalBranch, retries)
1714 }
1715
Philip Zeyligerf2872992025-05-22 10:35:28 -07001716 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1717 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001718 out, err = cmd.CombinedOutput()
1719
1720 if err == nil {
1721 // Success! Break out of the retry loop
1722 break
1723 }
1724
1725 // Check if this is the "refusing to update checked out branch" error
1726 if !strings.Contains(string(out), "refusing to update checked out branch") {
1727 // This is a different error, so don't retry
1728 break
1729 }
1730
1731 // If we're on the last retry, we'll report the error
1732 if retries == 9 {
1733 break
1734 }
1735 }
1736
1737 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001738 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001739 } else {
1740 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001741 // Update the agent's branch name if we ended up using a different one
1742 if branch != originalBranch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001743 ags.branchName = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001744 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001745 }
1746 }
1747
1748 // If we found new commits, create a message
1749 if len(commits) > 0 {
1750 msg := AgentMessage{
1751 Type: CommitMessageType,
1752 Timestamp: time.Now(),
1753 Commits: commits,
1754 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001755 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001756 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001757 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001758}
1759
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001760func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001761 return strings.Map(func(r rune) rune {
1762 // lowercase
1763 if r >= 'A' && r <= 'Z' {
1764 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001765 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001766 // replace spaces with dashes
1767 if r == ' ' {
1768 return '-'
1769 }
1770 // allow alphanumerics and dashes
1771 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1772 return r
1773 }
1774 return -1
1775 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001776}
1777
1778// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1779// and returns an array of GitCommit structs.
1780func parseGitLog(output string) []GitCommit {
1781 var commits []GitCommit
1782
1783 // No output means no commits
1784 if len(output) == 0 {
1785 return commits
1786 }
1787
1788 // Split by NULL byte
1789 parts := strings.Split(output, "\x00")
1790
1791 // Process in triplets (hash, subject, body)
1792 for i := 0; i < len(parts); i++ {
1793 // Skip empty parts
1794 if parts[i] == "" {
1795 continue
1796 }
1797
1798 // This should be a hash
1799 hash := strings.TrimSpace(parts[i])
1800
1801 // Make sure we have at least a subject part available
1802 if i+1 >= len(parts) {
1803 break // No more parts available
1804 }
1805
1806 // Get the subject
1807 subject := strings.TrimSpace(parts[i+1])
1808
1809 // Get the body if available
1810 body := ""
1811 if i+2 < len(parts) {
1812 body = strings.TrimSpace(parts[i+2])
1813 }
1814
1815 // Skip to the next triplet
1816 i += 2
1817
1818 commits = append(commits, GitCommit{
1819 Hash: hash,
1820 Subject: subject,
1821 Body: body,
1822 })
1823 }
1824
1825 return commits
1826}
1827
1828func repoRoot(ctx context.Context, dir string) (string, error) {
1829 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1830 stderr := new(strings.Builder)
1831 cmd.Stderr = stderr
1832 cmd.Dir = dir
1833 out, err := cmd.Output()
1834 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001835 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07001836 }
1837 return strings.TrimSpace(string(out)), nil
1838}
1839
1840func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1841 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1842 stderr := new(strings.Builder)
1843 cmd.Stderr = stderr
1844 cmd.Dir = dir
1845 out, err := cmd.Output()
1846 if err != nil {
1847 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1848 }
1849 // TODO: validate that out is valid hex
1850 return strings.TrimSpace(string(out)), nil
1851}
1852
1853// isValidGitSHA validates if a string looks like a valid git SHA hash.
1854// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1855func isValidGitSHA(sha string) bool {
1856 // Git SHA must be a hexadecimal string with at least 4 characters
1857 if len(sha) < 4 || len(sha) > 40 {
1858 return false
1859 }
1860
1861 // Check if the string only contains hexadecimal characters
1862 for _, char := range sha {
1863 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1864 return false
1865 }
1866 }
1867
1868 return true
1869}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001870
1871// getGitOrigin returns the URL of the git remote 'origin' if it exists
1872func getGitOrigin(ctx context.Context, dir string) string {
1873 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1874 cmd.Dir = dir
1875 stderr := new(strings.Builder)
1876 cmd.Stderr = stderr
1877 out, err := cmd.Output()
1878 if err != nil {
1879 return ""
1880 }
1881 return strings.TrimSpace(string(out))
1882}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001883
Philip Zeyligerf2872992025-05-22 10:35:28 -07001884// TODO(philip): Remove together with restartConversation
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001885func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
1886 cmd := exec.CommandContext(ctx, "git", "stash")
1887 cmd.Dir = workingDir
1888 if out, err := cmd.CombinedOutput(); err != nil {
1889 return fmt.Errorf("git stash: %s: %v", out, err)
1890 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001891 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001892 cmd.Dir = workingDir
1893 if out, err := cmd.CombinedOutput(); err != nil {
1894 return fmt.Errorf("git fetch: %s: %w", out, err)
1895 }
1896 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
1897 cmd.Dir = workingDir
1898 if out, err := cmd.CombinedOutput(); err != nil {
1899 return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
1900 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001901 a.gitState.lastHEAD = revision
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001902 return nil
1903}
1904
1905func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
1906 a.mu.Lock()
1907 a.title = ""
1908 a.firstMessageIndex = len(a.history)
1909 a.convo = a.initConvo()
1910 gitReset := func() error {
1911 if a.config.InDocker && rev != "" {
1912 err := a.initGitRevision(ctx, a.workingDir, rev)
1913 if err != nil {
1914 return err
1915 }
1916 } else if !a.config.InDocker && rev != "" {
1917 return fmt.Errorf("Not resetting git repo when working outside of a container.")
1918 }
1919 return nil
1920 }
1921 err := gitReset()
1922 a.mu.Unlock()
1923 if err != nil {
1924 a.pushToOutbox(a.config.Context, errorMessage(err))
1925 }
1926
1927 a.pushToOutbox(a.config.Context, AgentMessage{
1928 Type: AgentMessageType, Content: "Conversation restarted.",
1929 })
1930 if initialPrompt != "" {
1931 a.UserMessage(ctx, initialPrompt)
1932 }
1933 return nil
1934}
1935
1936func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
1937 msg := `The user has requested a suggestion for a re-prompt.
1938
1939 Given the current conversation thus far, suggest a re-prompt that would
1940 capture the instructions and feedback so far, as well as any
1941 research or other information that would be helpful in implementing
1942 the task.
1943
1944 Reply with ONLY the reprompt text.
1945 `
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001946 userMessage := llm.UserStringMessage(msg)
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001947 // By doing this in a subconversation, the agent doesn't call tools (because
1948 // there aren't any), and there's not a concurrency risk with on-going other
1949 // outstanding conversations.
1950 convo := a.convo.SubConvoWithHistory()
1951 resp, err := convo.SendMessage(userMessage)
1952 if err != nil {
1953 a.pushToOutbox(ctx, errorMessage(err))
1954 return "", err
1955 }
1956 textContent := collectTextContent(resp)
1957 return textContent, nil
1958}
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001959
1960// systemPromptData contains the data used to render the system prompt template
1961type systemPromptData struct {
1962 EditPrompt string
1963 ClientGOOS string
1964 ClientGOARCH string
1965 WorkingDir string
1966 RepoRoot string
1967 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001968 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001969}
1970
1971// renderSystemPrompt renders the system prompt template.
1972func (a *Agent) renderSystemPrompt() string {
1973 // Determine the appropriate edit prompt based on config
1974 var editPrompt string
1975 if a.config.UseAnthropicEdit {
1976 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."
1977 } else {
1978 editPrompt = "Then use the patch tool to make those edits. Combine all edits to any given file into a single patch tool call."
1979 }
1980
1981 data := systemPromptData{
1982 EditPrompt: editPrompt,
1983 ClientGOOS: a.config.ClientGOOS,
1984 ClientGOARCH: a.config.ClientGOARCH,
1985 WorkingDir: a.workingDir,
1986 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07001987 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001988 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001989 }
1990
1991 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1992 if err != nil {
1993 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1994 }
1995 buf := new(strings.Builder)
1996 err = tmpl.Execute(buf, data)
1997 if err != nil {
1998 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1999 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002000 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002001 return buf.String()
2002}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002003
2004// StateTransitionIterator provides an iterator over state transitions.
2005type StateTransitionIterator interface {
2006 // Next blocks until a new state transition is available or context is done.
2007 // Returns nil if the context is cancelled.
2008 Next() *StateTransition
2009 // Close removes the listener and cleans up resources.
2010 Close()
2011}
2012
2013// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2014type StateTransitionIteratorImpl struct {
2015 agent *Agent
2016 ctx context.Context
2017 ch chan StateTransition
2018 unsubscribe func()
2019}
2020
2021// Next blocks until a new state transition is available or the context is cancelled.
2022func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2023 select {
2024 case <-s.ctx.Done():
2025 return nil
2026 case transition, ok := <-s.ch:
2027 if !ok {
2028 return nil
2029 }
2030 transitionCopy := transition
2031 return &transitionCopy
2032 }
2033}
2034
2035// Close removes the listener and cleans up resources.
2036func (s *StateTransitionIteratorImpl) Close() {
2037 if s.unsubscribe != nil {
2038 s.unsubscribe()
2039 s.unsubscribe = nil
2040 }
2041}
2042
2043// NewStateTransitionIterator returns an iterator that receives state transitions.
2044func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2045 a.mu.Lock()
2046 defer a.mu.Unlock()
2047
2048 // Create channel to receive state transitions
2049 ch := make(chan StateTransition, 10)
2050
2051 // Add a listener to the state machine
2052 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2053
2054 return &StateTransitionIteratorImpl{
2055 agent: a,
2056 ctx: ctx,
2057 ch: ch,
2058 unsubscribe: unsubscribe,
2059 }
2060}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002061
2062// setupGitHooks creates or updates git hooks in the specified working directory.
2063func setupGitHooks(workingDir string) error {
2064 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2065
2066 _, err := os.Stat(hooksDir)
2067 if os.IsNotExist(err) {
2068 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2069 }
2070 if err != nil {
2071 return fmt.Errorf("error checking git hooks directory: %w", err)
2072 }
2073
2074 // Define the post-commit hook content
2075 postCommitHook := `#!/bin/bash
2076echo "<post_commit_hook>"
2077echo "Please review this commit message and fix it if it is incorrect."
2078echo "This hook only echos the commit message; it does not modify it."
2079echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2080echo "<last_commit_message>"
2081git log -1 --pretty=%B
2082echo "</last_commit_message>"
2083echo "</post_commit_hook>"
2084`
2085
2086 // Define the prepare-commit-msg hook content
2087 prepareCommitMsgHook := `#!/bin/bash
2088# Add Co-Authored-By and Change-ID trailers to commit messages
2089# Check if these trailers already exist before adding them
2090
2091commit_file="$1"
2092COMMIT_SOURCE="$2"
2093
2094# Skip for merges, squashes, or when using a commit template
2095if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2096 [ "$COMMIT_SOURCE" = "squash" ]; then
2097 exit 0
2098fi
2099
2100commit_msg=$(cat "$commit_file")
2101
2102needs_co_author=true
2103needs_change_id=true
2104
2105# Check if commit message already has Co-Authored-By trailer
2106if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2107 needs_co_author=false
2108fi
2109
2110# Check if commit message already has Change-ID trailer
2111if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2112 needs_change_id=false
2113fi
2114
2115# Only modify if at least one trailer needs to be added
2116if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
2117 # Ensure there's a blank line before trailers
2118 if [ -s "$commit_file" ] && [ "$(tail -1 "$commit_file" | tr -d '\n')" != "" ]; then
2119 echo "" >> "$commit_file"
2120 fi
2121
2122 # Add trailers if needed
2123 if [ "$needs_co_author" = true ]; then
2124 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2125 fi
2126
2127 if [ "$needs_change_id" = true ]; then
2128 change_id=$(openssl rand -hex 8)
2129 echo "Change-ID: s${change_id}k" >> "$commit_file"
2130 fi
2131fi
2132`
2133
2134 // Update or create the post-commit hook
2135 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2136 if err != nil {
2137 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2138 }
2139
2140 // Update or create the prepare-commit-msg hook
2141 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2142 if err != nil {
2143 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2144 }
2145
2146 return nil
2147}
2148
2149// updateOrCreateHook creates a new hook file or updates an existing one
2150// by appending the new content if it doesn't already contain it.
2151func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2152 // Check if the hook already exists
2153 buf, err := os.ReadFile(hookPath)
2154 if os.IsNotExist(err) {
2155 // Hook doesn't exist, create it
2156 err = os.WriteFile(hookPath, []byte(content), 0o755)
2157 if err != nil {
2158 return fmt.Errorf("failed to create hook: %w", err)
2159 }
2160 return nil
2161 }
2162 if err != nil {
2163 return fmt.Errorf("error reading existing hook: %w", err)
2164 }
2165
2166 // Hook exists, check if our content is already in it by looking for a distinctive line
2167 code := string(buf)
2168 if strings.Contains(code, distinctiveLine) {
2169 // Already contains our content, nothing to do
2170 return nil
2171 }
2172
2173 // Append our content to the existing hook
2174 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2175 if err != nil {
2176 return fmt.Errorf("failed to open hook for appending: %w", err)
2177 }
2178 defer f.Close()
2179
2180 // Ensure there's a newline at the end of the existing content if needed
2181 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2182 _, err = f.WriteString("\n")
2183 if err != nil {
2184 return fmt.Errorf("failed to add newline to hook: %w", err)
2185 }
2186 }
2187
2188 // Add a separator before our content
2189 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2190 if err != nil {
2191 return fmt.Errorf("failed to append to hook: %w", err)
2192 }
2193
2194 return nil
2195}