blob: 5e28bfc731573c48cc577121fac468dc96a1f5a8 [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 Zeyligerb5739402025-06-02 07:04:34 -070037// EndFeedback represents user feedback when ending a session
38type EndFeedback struct {
39 Happy bool `json:"happy"`
40 Comment string `json:"comment"`
41}
42
Philip Zeyligerb7c58752025-05-01 10:10:17 -070043type MessageIterator interface {
44 // Next blocks until the next message is available. It may
45 // return nil if the underlying iterator context is done.
46 Next() *AgentMessage
47 Close()
48}
49
Earl Lee2e463fb2025-04-17 11:22:22 -070050type CodingAgent interface {
51 // Init initializes an agent inside a docker container.
52 Init(AgentInit) error
53
54 // Ready returns a channel closed after Init successfully called.
55 Ready() <-chan struct{}
56
57 // URL reports the HTTP URL of this agent.
58 URL() string
59
60 // UserMessage enqueues a message to the agent and returns immediately.
61 UserMessage(ctx context.Context, msg string)
62
Philip Zeyligerb7c58752025-05-01 10:10:17 -070063 // Returns an iterator that finishes when the context is done and
64 // starts with the given message index.
65 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070066
Philip Zeyligereab12de2025-05-14 02:35:53 +000067 // Returns an iterator that notifies of state transitions until the context is done.
68 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
69
Earl Lee2e463fb2025-04-17 11:22:22 -070070 // Loop begins the agent loop returns only when ctx is cancelled.
71 Loop(ctx context.Context)
72
Sean McCulloughedc88dc2025-04-30 02:55:01 +000073 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070074
75 CancelToolUse(toolUseID string, cause error) error
76
77 // Returns a subset of the agent's message history.
78 Messages(start int, end int) []AgentMessage
79
80 // Returns the current number of messages in the history
81 MessageCount() int
82
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070083 TotalUsage() conversation.CumulativeUsage
84 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070085
Earl Lee2e463fb2025-04-17 11:22:22 -070086 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000087 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070088
89 // Diff returns a unified diff of changes made since the agent was instantiated.
90 // If commit is non-nil, it shows the diff for just that specific commit.
91 Diff(commit *string) (string, error)
92
Philip Zeyliger49edc922025-05-14 09:45:45 -070093 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
94 // starts out as the commit where sketch started, but a user can move it if need
95 // be, for example in the case of a rebase. It is stored as a git tag.
96 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070097
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000098 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
99 // (Typically, this is "sketch-base")
100 SketchGitBaseRef() string
101
Earl Lee2e463fb2025-04-17 11:22:22 -0700102 // Title returns the current title of the conversation.
103 Title() string
104
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000105 // BranchName returns the git branch name for the conversation.
106 BranchName() string
107
Earl Lee2e463fb2025-04-17 11:22:22 -0700108 // OS returns the operating system of the client.
109 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000110
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000111 // SessionID returns the unique session identifier.
112 SessionID() string
113
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000114 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700115 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000116
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000117 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
118 OutstandingLLMCallCount() int
119
120 // OutstandingToolCalls returns the names of outstanding tool calls.
121 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000122 OutsideOS() string
123 OutsideHostname() string
124 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000125 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000126 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
127 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700128
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700129 // IsInContainer returns true if the agent is running in a container
130 IsInContainer() bool
131 // FirstMessageIndex returns the index of the first message in the current conversation
132 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700133
134 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700135 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
136 CurrentTodoContent() string
Philip Zeyligerb5739402025-06-02 07:04:34 -0700137 // GetEndFeedback returns the end session feedback
138 GetEndFeedback() *EndFeedback
139 // SetEndFeedback sets the end session feedback
140 SetEndFeedback(feedback *EndFeedback)
Earl Lee2e463fb2025-04-17 11:22:22 -0700141}
142
143type CodingAgentMessageType string
144
145const (
146 UserMessageType CodingAgentMessageType = "user"
147 AgentMessageType CodingAgentMessageType = "agent"
148 ErrorMessageType CodingAgentMessageType = "error"
149 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
150 ToolUseMessageType CodingAgentMessageType = "tool"
151 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
152 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
153
154 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
155)
156
157type AgentMessage struct {
158 Type CodingAgentMessageType `json:"type"`
159 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
160 EndOfTurn bool `json:"end_of_turn"`
161
162 Content string `json:"content"`
163 ToolName string `json:"tool_name,omitempty"`
164 ToolInput string `json:"input,omitempty"`
165 ToolResult string `json:"tool_result,omitempty"`
166 ToolError bool `json:"tool_error,omitempty"`
167 ToolCallId string `json:"tool_call_id,omitempty"`
168
169 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
170 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
171
Sean McCulloughd9f13372025-04-21 15:08:49 -0700172 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
173 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
174
Earl Lee2e463fb2025-04-17 11:22:22 -0700175 // Commits is a list of git commits for a commit message
176 Commits []*GitCommit `json:"commits,omitempty"`
177
178 Timestamp time.Time `json:"timestamp"`
179 ConversationID string `json:"conversation_id"`
180 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700181 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700182
183 // Message timing information
184 StartTime *time.Time `json:"start_time,omitempty"`
185 EndTime *time.Time `json:"end_time,omitempty"`
186 Elapsed *time.Duration `json:"elapsed,omitempty"`
187
188 // Turn duration - the time taken for a complete agent turn
189 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
190
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000191 // HideOutput indicates that this message should not be rendered in the UI.
192 // This is useful for subconversations that generate output that shouldn't be shown to the user.
193 HideOutput bool `json:"hide_output,omitempty"`
194
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700195 // TodoContent contains the agent's todo file content when it has changed
196 TodoContent *string `json:"todo_content,omitempty"`
197
Earl Lee2e463fb2025-04-17 11:22:22 -0700198 Idx int `json:"idx"`
199}
200
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000201// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700202func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700203 if convo == nil {
204 m.ConversationID = ""
205 m.ParentConversationID = nil
206 return
207 }
208 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000209 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700210 if convo.Parent != nil {
211 m.ParentConversationID = &convo.Parent.ID
212 }
213}
214
Earl Lee2e463fb2025-04-17 11:22:22 -0700215// GitCommit represents a single git commit for a commit message
216type GitCommit struct {
217 Hash string `json:"hash"` // Full commit hash
218 Subject string `json:"subject"` // Commit subject line
219 Body string `json:"body"` // Full commit message body
220 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
221}
222
223// ToolCall represents a single tool call within an agent message
224type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700225 Name string `json:"name"`
226 Input string `json:"input"`
227 ToolCallId string `json:"tool_call_id"`
228 ResultMessage *AgentMessage `json:"result_message,omitempty"`
229 Args string `json:"args,omitempty"`
230 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700231}
232
233func (a *AgentMessage) Attr() slog.Attr {
234 var attrs []any = []any{
235 slog.String("type", string(a.Type)),
236 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700237 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700238 if a.EndOfTurn {
239 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
240 }
241 if a.Content != "" {
242 attrs = append(attrs, slog.String("content", a.Content))
243 }
244 if a.ToolName != "" {
245 attrs = append(attrs, slog.String("tool_name", a.ToolName))
246 }
247 if a.ToolInput != "" {
248 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
249 }
250 if a.Elapsed != nil {
251 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
252 }
253 if a.TurnDuration != nil {
254 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
255 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700256 if len(a.ToolResult) > 0 {
257 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700258 }
259 if a.ToolError {
260 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
261 }
262 if len(a.ToolCalls) > 0 {
263 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
264 for i, tc := range a.ToolCalls {
265 toolCallAttrs = append(toolCallAttrs, slog.Group(
266 fmt.Sprintf("tool_call_%d", i),
267 slog.String("name", tc.Name),
268 slog.String("input", tc.Input),
269 ))
270 }
271 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
272 }
273 if a.ConversationID != "" {
274 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
275 }
276 if a.ParentConversationID != nil {
277 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
278 }
279 if a.Usage != nil && !a.Usage.IsZero() {
280 attrs = append(attrs, a.Usage.Attr())
281 }
282 // TODO: timestamp, convo ids, idx?
283 return slog.Group("agent_message", attrs...)
284}
285
286func errorMessage(err error) AgentMessage {
287 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
288 if os.Getenv(("DEBUG")) == "1" {
289 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
290 }
291
292 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
293}
294
295func budgetMessage(err error) AgentMessage {
296 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
297}
298
299// ConvoInterface defines the interface for conversation interactions
300type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700301 CumulativeUsage() conversation.CumulativeUsage
302 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700303 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700304 SendMessage(message llm.Message) (*llm.Response, error)
305 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700306 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000307 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700308 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700309 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700310 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700311}
312
Philip Zeyligerf2872992025-05-22 10:35:28 -0700313// AgentGitState holds the state necessary for pushing to a remote git repo
314// when HEAD changes. If gitRemoteAddr is set, then we push to sketch/
315// any time we notice we need to.
316type AgentGitState struct {
317 mu sync.Mutex // protects following
318 lastHEAD string // hash of the last HEAD that was pushed to the host
319 gitRemoteAddr string // HTTP URL of the host git repo
320 seenCommits map[string]bool // Track git commits we've already seen (by hash)
321 branchName string
322}
323
324func (ags *AgentGitState) SetBranchName(branchName string) {
325 ags.mu.Lock()
326 defer ags.mu.Unlock()
327 ags.branchName = branchName
328}
329
330func (ags *AgentGitState) BranchName() string {
331 ags.mu.Lock()
332 defer ags.mu.Unlock()
333 return ags.branchName
334}
335
Earl Lee2e463fb2025-04-17 11:22:22 -0700336type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700337 convo ConvoInterface
338 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700339 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700340 workingDir string
341 repoRoot string // workingDir may be a subdir of repoRoot
342 url string
343 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000344 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700345 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000346 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700347 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700348 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700349 title string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000350 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700351 // State machine to track agent state
352 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000353 // Outside information
354 outsideHostname string
355 outsideOS string
356 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000357 // URL of the git remote 'origin' if it exists
358 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700359
360 // Time when the current turn started (reset at the beginning of InnerLoop)
361 startOfTurn time.Time
362
363 // Inbox - for messages from the user to the agent.
364 // sent on by UserMessage
365 // . e.g. when user types into the chat textarea
366 // read from by GatherMessages
367 inbox chan string
368
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000369 // protects cancelTurn
370 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700371 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000372 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700373
374 // protects following
375 mu sync.Mutex
376
377 // Stores all messages for this agent
378 history []AgentMessage
379
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700380 // Iterators add themselves here when they're ready to be notified of new messages.
381 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700382
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000383 // Track outstanding LLM call IDs
384 outstandingLLMCalls map[string]struct{}
385
386 // Track outstanding tool calls by ID with their names
387 outstandingToolCalls map[string]string
Sean McCullough364f7412025-06-02 00:55:44 +0000388
389 // Port monitoring
390 portMonitor *PortMonitor
Philip Zeyligerb5739402025-06-02 07:04:34 -0700391
392 // End session feedback
393 endFeedback *EndFeedback
Earl Lee2e463fb2025-04-17 11:22:22 -0700394}
395
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700396// NewIterator implements CodingAgent.
397func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
398 a.mu.Lock()
399 defer a.mu.Unlock()
400
401 return &MessageIteratorImpl{
402 agent: a,
403 ctx: ctx,
404 nextMessageIdx: nextMessageIdx,
405 ch: make(chan *AgentMessage, 100),
406 }
407}
408
409type MessageIteratorImpl struct {
410 agent *Agent
411 ctx context.Context
412 nextMessageIdx int
413 ch chan *AgentMessage
414 subscribed bool
415}
416
417func (m *MessageIteratorImpl) Close() {
418 m.agent.mu.Lock()
419 defer m.agent.mu.Unlock()
420 // Delete ourselves from the subscribers list
421 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
422 return x == m.ch
423 })
424 close(m.ch)
425}
426
427func (m *MessageIteratorImpl) Next() *AgentMessage {
428 // We avoid subscription at creation to let ourselves catch up to "current state"
429 // before subscribing.
430 if !m.subscribed {
431 m.agent.mu.Lock()
432 if m.nextMessageIdx < len(m.agent.history) {
433 msg := &m.agent.history[m.nextMessageIdx]
434 m.nextMessageIdx++
435 m.agent.mu.Unlock()
436 return msg
437 }
438 // The next message doesn't exist yet, so let's subscribe
439 m.agent.subscribers = append(m.agent.subscribers, m.ch)
440 m.subscribed = true
441 m.agent.mu.Unlock()
442 }
443
444 for {
445 select {
446 case <-m.ctx.Done():
447 m.agent.mu.Lock()
448 // Delete ourselves from the subscribers list
449 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
450 return x == m.ch
451 })
452 m.subscribed = false
453 m.agent.mu.Unlock()
454 return nil
455 case msg, ok := <-m.ch:
456 if !ok {
457 // Close may have been called
458 return nil
459 }
460 if msg.Idx == m.nextMessageIdx {
461 m.nextMessageIdx++
462 return msg
463 }
464 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
465 panic("out of order message")
466 }
467 }
468}
469
Sean McCulloughd9d45812025-04-30 16:53:41 -0700470// Assert that Agent satisfies the CodingAgent interface.
471var _ CodingAgent = &Agent{}
472
473// StateName implements CodingAgent.
474func (a *Agent) CurrentStateName() string {
475 if a.stateMachine == nil {
476 return ""
477 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000478 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700479}
480
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700481// CurrentTodoContent returns the current todo list data as JSON.
482// It returns an empty string if no todos exist.
483func (a *Agent) CurrentTodoContent() string {
484 todoPath := claudetool.TodoFilePath(a.config.SessionID)
485 content, err := os.ReadFile(todoPath)
486 if err != nil {
487 return ""
488 }
489 return string(content)
490}
491
Philip Zeyligerb5739402025-06-02 07:04:34 -0700492// SetEndFeedback sets the end session feedback
493func (a *Agent) SetEndFeedback(feedback *EndFeedback) {
494 a.mu.Lock()
495 defer a.mu.Unlock()
496 a.endFeedback = feedback
497}
498
499// GetEndFeedback gets the end session feedback
500func (a *Agent) GetEndFeedback() *EndFeedback {
501 a.mu.Lock()
502 defer a.mu.Unlock()
503 return a.endFeedback
504}
505
Earl Lee2e463fb2025-04-17 11:22:22 -0700506func (a *Agent) URL() string { return a.url }
507
508// Title returns the current title of the conversation.
509// If no title has been set, returns an empty string.
510func (a *Agent) Title() string {
511 a.mu.Lock()
512 defer a.mu.Unlock()
513 return a.title
514}
515
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000516// BranchName returns the git branch name for the conversation.
517func (a *Agent) BranchName() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700518 return a.gitState.BranchName()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000519}
520
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000521// OutstandingLLMCallCount returns the number of outstanding LLM calls.
522func (a *Agent) OutstandingLLMCallCount() int {
523 a.mu.Lock()
524 defer a.mu.Unlock()
525 return len(a.outstandingLLMCalls)
526}
527
528// OutstandingToolCalls returns the names of outstanding tool calls.
529func (a *Agent) OutstandingToolCalls() []string {
530 a.mu.Lock()
531 defer a.mu.Unlock()
532
533 tools := make([]string, 0, len(a.outstandingToolCalls))
534 for _, toolName := range a.outstandingToolCalls {
535 tools = append(tools, toolName)
536 }
537 return tools
538}
539
Earl Lee2e463fb2025-04-17 11:22:22 -0700540// OS returns the operating system of the client.
541func (a *Agent) OS() string {
542 return a.config.ClientGOOS
543}
544
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000545func (a *Agent) SessionID() string {
546 return a.config.SessionID
547}
548
Philip Zeyliger18532b22025-04-23 21:11:46 +0000549// OutsideOS returns the operating system of the outside system.
550func (a *Agent) OutsideOS() string {
551 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000552}
553
Philip Zeyliger18532b22025-04-23 21:11:46 +0000554// OutsideHostname returns the hostname of the outside system.
555func (a *Agent) OutsideHostname() string {
556 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000557}
558
Philip Zeyliger18532b22025-04-23 21:11:46 +0000559// OutsideWorkingDir returns the working directory on the outside system.
560func (a *Agent) OutsideWorkingDir() string {
561 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000562}
563
564// GitOrigin returns the URL of the git remote 'origin' if it exists.
565func (a *Agent) GitOrigin() string {
566 return a.gitOrigin
567}
568
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000569func (a *Agent) OpenBrowser(url string) {
570 if !a.IsInContainer() {
571 browser.Open(url)
572 return
573 }
574 // We're in Docker, need to send a request to the Git server
575 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700576 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000577 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700578 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000579 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700580 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000581 return
582 }
583 defer resp.Body.Close()
584 if resp.StatusCode == http.StatusOK {
585 return
586 }
587 body, _ := io.ReadAll(resp.Body)
588 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
589}
590
Sean McCullough96b60dd2025-04-30 09:49:10 -0700591// CurrentState returns the current state of the agent's state machine.
592func (a *Agent) CurrentState() State {
593 return a.stateMachine.CurrentState()
594}
595
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700596func (a *Agent) IsInContainer() bool {
597 return a.config.InDocker
598}
599
600func (a *Agent) FirstMessageIndex() int {
601 a.mu.Lock()
602 defer a.mu.Unlock()
603 return a.firstMessageIndex
604}
605
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000606// SetTitle sets the title of the conversation.
607func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700608 a.mu.Lock()
609 defer a.mu.Unlock()
610 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000611}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700612
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000613// SetBranch sets the branch name of the conversation.
614func (a *Agent) SetBranch(branchName string) {
615 a.mu.Lock()
616 defer a.mu.Unlock()
Philip Zeyligerf2872992025-05-22 10:35:28 -0700617 a.gitState.SetBranchName(branchName)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000618 convo, ok := a.convo.(*conversation.Convo)
619 if ok {
620 convo.ExtraData["branch"] = branchName
621 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700622}
623
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000624// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700625func (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 +0000626 // Track the tool call
627 a.mu.Lock()
628 a.outstandingToolCalls[id] = toolName
629 a.mu.Unlock()
630}
631
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700632// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
633// If there's only one element in the array and it's a text type, it returns that text directly.
634// It also processes nested ToolResult arrays recursively.
635func contentToString(contents []llm.Content) string {
636 if len(contents) == 0 {
637 return ""
638 }
639
640 // If there's only one element and it's a text type, return it directly
641 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
642 return contents[0].Text
643 }
644
645 // Otherwise, concatenate all text content
646 var result strings.Builder
647 for _, content := range contents {
648 if content.Type == llm.ContentTypeText {
649 result.WriteString(content.Text)
650 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
651 // Recursively process nested tool results
652 result.WriteString(contentToString(content.ToolResult))
653 }
654 }
655
656 return result.String()
657}
658
Earl Lee2e463fb2025-04-17 11:22:22 -0700659// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700660func (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 +0000661 // Remove the tool call from outstanding calls
662 a.mu.Lock()
663 delete(a.outstandingToolCalls, toolID)
664 a.mu.Unlock()
665
Earl Lee2e463fb2025-04-17 11:22:22 -0700666 m := AgentMessage{
667 Type: ToolUseMessageType,
668 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700669 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700670 ToolError: content.ToolError,
671 ToolName: toolName,
672 ToolInput: string(toolInput),
673 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700674 StartTime: content.ToolUseStartTime,
675 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700676 }
677
678 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700679 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
680 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700681 m.Elapsed = &elapsed
682 }
683
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700684 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700685 a.pushToOutbox(ctx, m)
686}
687
688// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700689func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000690 a.mu.Lock()
691 defer a.mu.Unlock()
692 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700693 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
694}
695
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700696// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700697// that need to be displayed (as well as tool calls that we send along when
698// they're done). (It would be reasonable to also mention tool calls when they're
699// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700700func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000701 // Remove the LLM call from outstanding calls
702 a.mu.Lock()
703 delete(a.outstandingLLMCalls, id)
704 a.mu.Unlock()
705
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700706 if resp == nil {
707 // LLM API call failed
708 m := AgentMessage{
709 Type: ErrorMessageType,
710 Content: "API call failed, type 'continue' to try again",
711 }
712 m.SetConvo(convo)
713 a.pushToOutbox(ctx, m)
714 return
715 }
716
Earl Lee2e463fb2025-04-17 11:22:22 -0700717 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700718 if convo.Parent == nil { // subconvos never end the turn
719 switch resp.StopReason {
720 case llm.StopReasonToolUse:
721 // Check whether any of the tool calls are for tools that should end the turn
722 ToolSearch:
723 for _, part := range resp.Content {
724 if part.Type != llm.ContentTypeToolUse {
725 continue
726 }
Sean McCullough021557a2025-05-05 23:20:53 +0000727 // Find the tool by name
728 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700729 if tool.Name == part.ToolName {
730 endOfTurn = tool.EndsTurn
731 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000732 }
733 }
Sean McCullough021557a2025-05-05 23:20:53 +0000734 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700735 default:
736 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000737 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700738 }
739 m := AgentMessage{
740 Type: AgentMessageType,
741 Content: collectTextContent(resp),
742 EndOfTurn: endOfTurn,
743 Usage: &resp.Usage,
744 StartTime: resp.StartTime,
745 EndTime: resp.EndTime,
746 }
747
748 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700749 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700750 var toolCalls []ToolCall
751 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700752 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700753 toolCalls = append(toolCalls, ToolCall{
754 Name: part.ToolName,
755 Input: string(part.ToolInput),
756 ToolCallId: part.ID,
757 })
758 }
759 }
760 m.ToolCalls = toolCalls
761 }
762
763 // Calculate the elapsed time if both start and end times are set
764 if resp.StartTime != nil && resp.EndTime != nil {
765 elapsed := resp.EndTime.Sub(*resp.StartTime)
766 m.Elapsed = &elapsed
767 }
768
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700769 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700770 a.pushToOutbox(ctx, m)
771}
772
773// WorkingDir implements CodingAgent.
774func (a *Agent) WorkingDir() string {
775 return a.workingDir
776}
777
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000778// RepoRoot returns the git repository root directory.
779func (a *Agent) RepoRoot() string {
780 return a.repoRoot
781}
782
Earl Lee2e463fb2025-04-17 11:22:22 -0700783// MessageCount implements CodingAgent.
784func (a *Agent) MessageCount() int {
785 a.mu.Lock()
786 defer a.mu.Unlock()
787 return len(a.history)
788}
789
790// Messages implements CodingAgent.
791func (a *Agent) Messages(start int, end int) []AgentMessage {
792 a.mu.Lock()
793 defer a.mu.Unlock()
794 return slices.Clone(a.history[start:end])
795}
796
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700797func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700798 return a.originalBudget
799}
800
801// AgentConfig contains configuration for creating a new Agent.
802type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +0000803 Context context.Context
804 Service llm.Service
805 Budget conversation.Budget
806 GitUsername string
807 GitEmail string
808 SessionID string
809 ClientGOOS string
810 ClientGOARCH string
811 InDocker bool
812 OneShot bool
813 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000814 // Outside information
815 OutsideHostname string
816 OutsideOS string
817 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700818
819 // Outtie's HTTP to, e.g., open a browser
820 OutsideHTTP string
821 // Outtie's Git server
822 GitRemoteAddr string
823 // Commit to checkout from Outtie
824 Commit string
Earl Lee2e463fb2025-04-17 11:22:22 -0700825}
826
827// NewAgent creates a new Agent.
828// It is not usable until Init() is called.
829func NewAgent(config AgentConfig) *Agent {
830 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -0700831 config: config,
832 ready: make(chan struct{}),
833 inbox: make(chan string, 100),
834 subscribers: make([]chan *AgentMessage, 0),
835 startedAt: time.Now(),
836 originalBudget: config.Budget,
837 gitState: AgentGitState{
838 seenCommits: make(map[string]bool),
839 gitRemoteAddr: config.GitRemoteAddr,
840 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000841 outsideHostname: config.OutsideHostname,
842 outsideOS: config.OutsideOS,
843 outsideWorkingDir: config.OutsideWorkingDir,
844 outstandingLLMCalls: make(map[string]struct{}),
845 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700846 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700847 workingDir: config.WorkingDir,
848 outsideHTTP: config.OutsideHTTP,
Sean McCullough364f7412025-06-02 00:55:44 +0000849 portMonitor: NewPortMonitor(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700850 }
851 return agent
852}
853
854type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700855 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -0700856
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700857 InDocker bool
858 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -0700859}
860
861func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700862 if a.convo != nil {
863 return fmt.Errorf("Agent.Init: already initialized")
864 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700865 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -0700866 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700867
Philip Zeyligerf2872992025-05-22 10:35:28 -0700868 // If a remote git addr was specified, we configure the remote
869 if a.gitState.gitRemoteAddr != "" {
870 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
871 cmd := exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", a.gitState.gitRemoteAddr)
872 cmd.Dir = a.workingDir
873 if out, err := cmd.CombinedOutput(); err != nil {
874 return fmt.Errorf("git remote add: %s: %v", out, err)
875 }
876 // sketch-host is a git repo hosted by "outtie sketch". When it notices a 'git fetch',
877 // it runs "git fetch" underneath the covers to get its latest commits. By configuring
878 // an additional remote.sketch-host.fetch, we make "origin/main" on innie sketch look like
879 // origin/main on outtie sketch, which should make it easier to rebase.
880 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.sketch-host.fetch",
881 "+refs/heads/feature/*:refs/remotes/origin/feature/*")
882 cmd.Dir = a.workingDir
883 if out, err := cmd.CombinedOutput(); err != nil {
884 return fmt.Errorf("git config --add: %s: %v", out, err)
885 }
886 }
887
888 // If a commit was specified, we fetch and reset to it.
889 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -0700890 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
891
Earl Lee2e463fb2025-04-17 11:22:22 -0700892 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700893 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -0700894 if out, err := cmd.CombinedOutput(); err != nil {
895 return fmt.Errorf("git stash: %s: %v", out, err)
896 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +0000897 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700898 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -0700899 if out, err := cmd.CombinedOutput(); err != nil {
900 return fmt.Errorf("git fetch: %s: %w", out, err)
901 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700902 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
903 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100904 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
905 // Remove git hooks if they exist and retry
906 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700907 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +0100908 if _, statErr := os.Stat(hookPath); statErr == nil {
909 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
910 slog.String("error", err.Error()),
911 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700912 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +0100913 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
914 }
915
916 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700917 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
918 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +0100919 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700920 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 +0100921 }
922 } else {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700923 return fmt.Errorf("git checkout %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +0100924 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700925 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700926 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700927
928 if ini.HostAddr != "" {
929 a.url = "http://" + ini.HostAddr
930 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700931
932 if !ini.NoGit {
933 repoRoot, err := repoRoot(ctx, a.workingDir)
934 if err != nil {
935 return fmt.Errorf("repoRoot: %w", err)
936 }
937 a.repoRoot = repoRoot
938
Earl Lee2e463fb2025-04-17 11:22:22 -0700939 if err != nil {
940 return fmt.Errorf("resolveRef: %w", err)
941 }
Philip Zeyliger49edc922025-05-14 09:45:45 -0700942
Josh Bleecher Snyder90993a02025-05-28 18:15:15 -0700943 if err := setupGitHooks(a.repoRoot); err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700944 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
945 }
946
Philip Zeyliger49edc922025-05-14 09:45:45 -0700947 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
948 cmd.Dir = repoRoot
949 if out, err := cmd.CombinedOutput(); err != nil {
950 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
951 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700952
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +0000953 slog.Info("running codebase analysis")
954 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
955 if err != nil {
956 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000957 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +0000958 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000959
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +0000960 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -0700961 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000962 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -0700963 }
964 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +0000965
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700966 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -0700967 }
Philip Zeyligerf2872992025-05-22 10:35:28 -0700968 a.gitState.lastHEAD = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -0700969 a.convo = a.initConvo()
970 close(a.ready)
971 return nil
972}
973
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -0700974//go:embed agent_system_prompt.txt
975var agentSystemPrompt string
976
Earl Lee2e463fb2025-04-17 11:22:22 -0700977// initConvo initializes the conversation.
978// It must not be called until all agent fields are initialized,
979// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700980func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -0700981 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700982 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -0700983 convo.PromptCaching = true
984 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +0000985 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000986 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -0700987
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000988 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
989 bashPermissionCheck := func(command string) error {
990 // Check if branch name is set
991 a.mu.Lock()
Philip Zeyligerf2872992025-05-22 10:35:28 -0700992 branchSet := a.gitState.BranchName() != ""
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +0000993 a.mu.Unlock()
994
995 // If branch is set, all commands are allowed
996 if branchSet {
997 return nil
998 }
999
1000 // If branch is not set, check if this is a git commit command
1001 willCommit, err := bashkit.WillRunGitCommit(command)
1002 if err != nil {
1003 // If there's an error checking, we should allow the command to proceed
1004 return nil
1005 }
1006
1007 // If it's a git commit and branch is not set, return an error
1008 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001009 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001010 }
1011
1012 return nil
1013 }
1014
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001015 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001016
Earl Lee2e463fb2025-04-17 11:22:22 -07001017 // Register all tools with the conversation
1018 // When adding, removing, or modifying tools here, double-check that the termui tool display
1019 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001020
1021 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001022 _, supportsScreenshots := a.config.Service.(*ant.Service)
1023 var bTools []*llm.Tool
1024 var browserCleanup func()
1025
1026 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1027 // Add cleanup function to context cancel
1028 go func() {
1029 <-a.config.Context.Done()
1030 browserCleanup()
1031 }()
1032 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001033
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001034 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001035 bashTool, claudetool.Keyword, claudetool.Patch,
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001036 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001037 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001038 }
1039
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001040 // One-shot mode is non-interactive, multiple choice requires human response
1041 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001042 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001043 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001044
1045 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -07001046 convo.Listener = a
1047 return convo
1048}
1049
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001050var multipleChoiceTool = &llm.Tool{
1051 Name: "multiplechoice",
1052 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.",
1053 EndsTurn: true,
1054 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001055 "type": "object",
1056 "description": "The question and a list of answers you would expect the user to choose from.",
1057 "properties": {
1058 "question": {
1059 "type": "string",
1060 "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?'"
1061 },
1062 "responseOptions": {
1063 "type": "array",
1064 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1065 "items": {
1066 "type": "object",
1067 "properties": {
1068 "caption": {
1069 "type": "string",
1070 "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'"
1071 },
1072 "responseText": {
1073 "type": "string",
1074 "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'"
1075 }
1076 },
1077 "required": ["caption", "responseText"]
1078 }
1079 }
1080 },
1081 "required": ["question", "responseOptions"]
1082}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001083 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1084 // The Run logic for "multiplechoice" tool is a no-op on the server.
1085 // The UI will present a list of options for the user to select from,
1086 // and that's it as far as "executing" the tool_use goes.
1087 // When the user *does* select one of the presented options, that
1088 // responseText gets sent as a chat message on behalf of the user.
1089 return llm.TextContent("end your turn and wait for the user to respond"), nil
1090 },
Sean McCullough485afc62025-04-28 14:28:39 -07001091}
1092
1093type MultipleChoiceOption struct {
1094 Caption string `json:"caption"`
1095 ResponseText string `json:"responseText"`
1096}
1097
1098type MultipleChoiceParams struct {
1099 Question string `json:"question"`
1100 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1101}
1102
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001103// branchExists reports whether branchName exists, either locally or in well-known remotes.
1104func branchExists(dir, branchName string) bool {
1105 refs := []string{
1106 "refs/heads/",
1107 "refs/remotes/origin/",
1108 "refs/remotes/sketch-host/",
1109 }
1110 for _, ref := range refs {
1111 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1112 cmd.Dir = dir
1113 if cmd.Run() == nil { // exit code 0 means branch exists
1114 return true
1115 }
1116 }
1117 return false
1118}
1119
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001120func (a *Agent) titleTool() *llm.Tool {
1121 description := `Sets the conversation title.`
1122 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001123 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001124 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001125 InputSchema: json.RawMessage(`{
1126 "type": "object",
1127 "properties": {
1128 "title": {
1129 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001130 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001131 }
1132 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001133 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001134}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001135 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001136 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001137 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001138 }
1139 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001140 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001141 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001142
1143 // We don't allow changing the title once set to be consistent with the previous behavior
1144 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001145 t := a.Title()
1146 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001147 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001148 }
1149
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001150 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001151 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001152 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001153
1154 a.SetTitle(params.Title)
1155 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001156 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001157 },
1158 }
1159 return titleTool
1160}
1161
1162func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001163 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 +00001164 preCommit := &llm.Tool{
1165 Name: "precommit",
1166 Description: description,
1167 InputSchema: json.RawMessage(`{
1168 "type": "object",
1169 "properties": {
1170 "branch_name": {
1171 "type": "string",
1172 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1173 }
1174 },
1175 "required": ["branch_name"]
1176}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001177 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001178 var params struct {
1179 BranchName string `json:"branch_name"`
1180 }
1181 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001182 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001183 }
1184
1185 b := a.BranchName()
1186 if b != "" {
Josh Bleecher Snyder44d1f1a2025-05-12 19:18:32 -07001187 return nil, fmt.Errorf("branch already set to %s; do not create a new branch", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001188 }
1189
1190 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001191 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001192 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001193 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001194 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001195 }
1196 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001197 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001198 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001199 }
1200
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001201 a.SetBranch(branchName)
Josh Bleecher Snyderf7bebdd2025-05-14 15:22:24 -07001202 response := fmt.Sprintf("switched to branch sketch/%q - DO NOT change branches unless explicitly requested", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001203
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001204 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1205 if err != nil {
1206 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1207 }
1208 if len(styleHint) > 0 {
1209 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001210 }
1211
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001212 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001213 },
1214 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001215 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001216}
1217
1218func (a *Agent) Ready() <-chan struct{} {
1219 return a.ready
1220}
1221
1222func (a *Agent) UserMessage(ctx context.Context, msg string) {
1223 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1224 a.inbox <- msg
1225}
1226
Earl Lee2e463fb2025-04-17 11:22:22 -07001227func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1228 return a.convo.CancelToolUse(toolUseID, cause)
1229}
1230
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001231func (a *Agent) CancelTurn(cause error) {
1232 a.cancelTurnMu.Lock()
1233 defer a.cancelTurnMu.Unlock()
1234 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001235 // Force state transition to cancelled state
1236 ctx := a.config.Context
1237 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001238 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001239 }
1240}
1241
1242func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001243 // Start port monitoring when the agent loop begins
1244 // Only monitor ports when running in a container
1245 if a.IsInContainer() {
1246 a.portMonitor.Start(ctxOuter)
1247 }
1248
Earl Lee2e463fb2025-04-17 11:22:22 -07001249 for {
1250 select {
1251 case <-ctxOuter.Done():
1252 return
1253 default:
1254 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001255 a.cancelTurnMu.Lock()
1256 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001257 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001258 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001259 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001260 a.cancelTurn = cancel
1261 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001262 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1263 if err != nil {
1264 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1265 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001266 cancel(nil)
1267 }
1268 }
1269}
1270
1271func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1272 if m.Timestamp.IsZero() {
1273 m.Timestamp = time.Now()
1274 }
1275
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001276 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1277 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1278 m.Content = m.ToolResult
1279 }
1280
Earl Lee2e463fb2025-04-17 11:22:22 -07001281 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1282 if m.EndOfTurn && m.Type == AgentMessageType {
1283 turnDuration := time.Since(a.startOfTurn)
1284 m.TurnDuration = &turnDuration
1285 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1286 }
1287
Earl Lee2e463fb2025-04-17 11:22:22 -07001288 a.mu.Lock()
1289 defer a.mu.Unlock()
1290 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001291 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001292 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001293
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001294 // Notify all subscribers
1295 for _, ch := range a.subscribers {
1296 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001297 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001298}
1299
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001300func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1301 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001302 if block {
1303 select {
1304 case <-ctx.Done():
1305 return m, ctx.Err()
1306 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001307 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001308 }
1309 }
1310 for {
1311 select {
1312 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001313 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001314 default:
1315 return m, nil
1316 }
1317 }
1318}
1319
Sean McCullough885a16a2025-04-30 02:49:25 +00001320// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001321func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001322 // Reset the start of turn time
1323 a.startOfTurn = time.Now()
1324
Sean McCullough96b60dd2025-04-30 09:49:10 -07001325 // Transition to waiting for user input state
1326 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1327
Sean McCullough885a16a2025-04-30 02:49:25 +00001328 // Process initial user message
1329 initialResp, err := a.processUserMessage(ctx)
1330 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001331 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001332 return err
1333 }
1334
1335 // Handle edge case where both initialResp and err are nil
1336 if initialResp == nil {
1337 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001338 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1339
Sean McCullough9f4b8082025-04-30 17:34:07 +00001340 a.pushToOutbox(ctx, errorMessage(err))
1341 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001342 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001343
Earl Lee2e463fb2025-04-17 11:22:22 -07001344 // We do this as we go, but let's also do it at the end of the turn
1345 defer func() {
1346 if _, err := a.handleGitCommits(ctx); err != nil {
1347 // Just log the error, don't stop execution
1348 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1349 }
1350 }()
1351
Sean McCullougha1e0e492025-05-01 10:51:08 -07001352 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001353 resp := initialResp
1354 for {
1355 // Check if we are over budget
1356 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001357 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001358 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001359 }
1360
1361 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001362 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001363 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001364 break
1365 }
1366
Sean McCullough96b60dd2025-04-30 09:49:10 -07001367 // Transition to tool use requested state
1368 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1369
Sean McCullough885a16a2025-04-30 02:49:25 +00001370 // Handle tool execution
1371 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1372 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001373 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001374 }
1375
Sean McCullougha1e0e492025-05-01 10:51:08 -07001376 if toolResp == nil {
1377 return fmt.Errorf("cannot continue conversation with a nil tool response")
1378 }
1379
Sean McCullough885a16a2025-04-30 02:49:25 +00001380 // Set the response for the next iteration
1381 resp = toolResp
1382 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001383
1384 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001385}
1386
1387// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001388func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001389 // Wait for at least one message from the user
1390 msgs, err := a.GatherMessages(ctx, true)
1391 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001392 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001393 return nil, err
1394 }
1395
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001396 userMessage := llm.Message{
1397 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001398 Content: msgs,
1399 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001400
Sean McCullough96b60dd2025-04-30 09:49:10 -07001401 // Transition to sending to LLM state
1402 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1403
Sean McCullough885a16a2025-04-30 02:49:25 +00001404 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001405 resp, err := a.convo.SendMessage(userMessage)
1406 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001407 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001408 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001409 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001410 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001411
Sean McCullough96b60dd2025-04-30 09:49:10 -07001412 // Transition to processing LLM response state
1413 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1414
Sean McCullough885a16a2025-04-30 02:49:25 +00001415 return resp, nil
1416}
1417
1418// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001419func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1420 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001421 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001422 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001423
Sean McCullough96b60dd2025-04-30 09:49:10 -07001424 // Transition to checking for cancellation state
1425 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1426
Sean McCullough885a16a2025-04-30 02:49:25 +00001427 // Check if the operation was cancelled by the user
1428 select {
1429 case <-ctx.Done():
1430 // Don't actually run any of the tools, but rather build a response
1431 // for each tool_use message letting the LLM know that user canceled it.
1432 var err error
1433 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001434 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001435 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001436 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001437 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001438 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001439 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001440 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001441 // Transition to running tool state
1442 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1443
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001444 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001445 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001446 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001447
1448 // Execute the tools
1449 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001450 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001451 if ctx.Err() != nil { // e.g. the user canceled the operation
1452 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001453 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001454 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001455 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001456 a.pushToOutbox(ctx, errorMessage(err))
1457 }
1458 }
1459
1460 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001461 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001462 autoqualityMessages := a.processGitChanges(ctx)
1463
1464 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001465 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001466 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001467 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001468 return false, nil
1469 }
1470
1471 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001472 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1473 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001474}
1475
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001476// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001477func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001478 // Check for git commits
1479 _, err := a.handleGitCommits(ctx)
1480 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001481 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001482 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001483 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001484 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001485}
1486
1487// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1488// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001489func (a *Agent) processGitChanges(ctx context.Context) []string {
1490 // Check for git commits after tool execution
1491 newCommits, err := a.handleGitCommits(ctx)
1492 if err != nil {
1493 // Just log the error, don't stop execution
1494 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1495 return nil
1496 }
1497
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001498 // Run mechanical checks if there was exactly one new commit.
1499 if len(newCommits) != 1 {
1500 return nil
1501 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001502 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001503 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1504 msg := a.codereview.RunMechanicalChecks(ctx)
1505 if msg != "" {
1506 a.pushToOutbox(ctx, AgentMessage{
1507 Type: AutoMessageType,
1508 Content: msg,
1509 Timestamp: time.Now(),
1510 })
1511 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001512 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001513
1514 return autoqualityMessages
1515}
1516
1517// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001518func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001519 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001520 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001521 msgs, err := a.GatherMessages(ctx, false)
1522 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001523 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001524 return false, nil
1525 }
1526
1527 // Inject any auto-generated messages from quality checks
1528 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001529 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001530 }
1531
1532 // Handle cancellation by appending a message about it
1533 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001534 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001535 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001536 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001537 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1538 } else if err := a.convo.OverBudget(); err != nil {
1539 // Handle budget issues by appending a message about it
1540 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 -07001541 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001542 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1543 }
1544
1545 // Combine tool results with user messages
1546 results = append(results, msgs...)
1547
1548 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001549 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001550 resp, err := a.convo.SendMessage(llm.Message{
1551 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001552 Content: results,
1553 })
1554 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001555 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001556 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1557 return true, nil // Return true to continue the conversation, but with no response
1558 }
1559
Sean McCullough96b60dd2025-04-30 09:49:10 -07001560 // Transition back to processing LLM response
1561 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1562
Sean McCullough885a16a2025-04-30 02:49:25 +00001563 if cancelled {
1564 return false, nil
1565 }
1566
1567 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001568}
1569
1570func (a *Agent) overBudget(ctx context.Context) error {
1571 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001572 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001573 m := budgetMessage(err)
1574 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001575 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001576 a.convo.ResetBudget(a.originalBudget)
1577 return err
1578 }
1579 return nil
1580}
1581
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001582func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001583 // Collect all text content
1584 var allText strings.Builder
1585 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001586 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001587 if allText.Len() > 0 {
1588 allText.WriteString("\n\n")
1589 }
1590 allText.WriteString(content.Text)
1591 }
1592 }
1593 return allText.String()
1594}
1595
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001596func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001597 a.mu.Lock()
1598 defer a.mu.Unlock()
1599 return a.convo.CumulativeUsage()
1600}
1601
Earl Lee2e463fb2025-04-17 11:22:22 -07001602// Diff returns a unified diff of changes made since the agent was instantiated.
1603func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001604 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001605 return "", fmt.Errorf("no initial commit reference available")
1606 }
1607
1608 // Find the repository root
1609 ctx := context.Background()
1610
1611 // If a specific commit hash is provided, show just that commit's changes
1612 if commit != nil && *commit != "" {
1613 // Validate that the commit looks like a valid git SHA
1614 if !isValidGitSHA(*commit) {
1615 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1616 }
1617
1618 // Get the diff for just this commit
1619 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1620 cmd.Dir = a.repoRoot
1621 output, err := cmd.CombinedOutput()
1622 if err != nil {
1623 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1624 }
1625 return string(output), nil
1626 }
1627
1628 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001629 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001630 cmd.Dir = a.repoRoot
1631 output, err := cmd.CombinedOutput()
1632 if err != nil {
1633 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1634 }
1635
1636 return string(output), nil
1637}
1638
Philip Zeyliger49edc922025-05-14 09:45:45 -07001639// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1640// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1641func (a *Agent) SketchGitBaseRef() string {
1642 if a.IsInContainer() {
1643 return "sketch-base"
1644 } else {
1645 return "sketch-base-" + a.SessionID()
1646 }
1647}
1648
1649// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1650func (a *Agent) SketchGitBase() string {
1651 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1652 cmd.Dir = a.repoRoot
1653 output, err := cmd.CombinedOutput()
1654 if err != nil {
1655 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1656 return "HEAD"
1657 }
1658 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001659}
1660
Pokey Rule7a113622025-05-12 10:58:45 +01001661// removeGitHooks removes the Git hooks directory from the repository
1662func removeGitHooks(_ context.Context, repoPath string) error {
1663 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1664
1665 // Check if hooks directory exists
1666 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1667 // Directory doesn't exist, nothing to do
1668 return nil
1669 }
1670
1671 // Remove the hooks directory
1672 err := os.RemoveAll(hooksDir)
1673 if err != nil {
1674 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1675 }
1676
1677 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001678 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001679 if err != nil {
1680 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1681 }
1682
1683 return nil
1684}
1685
Philip Zeyligerf2872992025-05-22 10:35:28 -07001686func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1687 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef())
1688 for _, msg := range msgs {
1689 a.pushToOutbox(ctx, msg)
1690 }
1691 return commits, error
1692}
1693
Earl Lee2e463fb2025-04-17 11:22:22 -07001694// handleGitCommits() highlights new commits to the user. When running
1695// under docker, new HEADs are pushed to a branch according to the title.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001696func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string) ([]AgentMessage, []*GitCommit, error) {
1697 ags.mu.Lock()
1698 defer ags.mu.Unlock()
1699
1700 msgs := []AgentMessage{}
1701 if repoRoot == "" {
1702 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001703 }
1704
Philip Zeyligerf2872992025-05-22 10:35:28 -07001705 head, err := resolveRef(ctx, repoRoot, "HEAD")
Earl Lee2e463fb2025-04-17 11:22:22 -07001706 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001707 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001708 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001709 if head == ags.lastHEAD {
1710 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001711 }
1712 defer func() {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001713 ags.lastHEAD = head
Earl Lee2e463fb2025-04-17 11:22:22 -07001714 }()
1715
1716 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1717 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1718 // to the last 100 commits.
1719 var commits []*GitCommit
1720
1721 // Get commits since the initial commit
1722 // Format: <hash>\0<subject>\0<body>\0
1723 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1724 // Limit to 100 commits to avoid overwhelming the user
Philip Zeyligerf2872992025-05-22 10:35:28 -07001725 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, head)
1726 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001727 output, err := cmd.Output()
1728 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001729 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001730 }
1731
1732 // Parse git log output and filter out already seen commits
1733 parsedCommits := parseGitLog(string(output))
1734
1735 var headCommit *GitCommit
1736
1737 // Filter out commits we've already seen
1738 for _, commit := range parsedCommits {
1739 if commit.Hash == head {
1740 headCommit = &commit
1741 }
1742
1743 // Skip if we've seen this commit before. If our head has changed, always include that.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001744 if ags.seenCommits[commit.Hash] && commit.Hash != head {
Earl Lee2e463fb2025-04-17 11:22:22 -07001745 continue
1746 }
1747
1748 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001749 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001750
1751 // Add to our list of new commits
1752 commits = append(commits, &commit)
1753 }
1754
Philip Zeyligerf2872992025-05-22 10:35:28 -07001755 if ags.gitRemoteAddr != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001756 if headCommit == nil {
1757 // I think this can only happen if we have a bug or if there's a race.
1758 headCommit = &GitCommit{}
1759 headCommit.Hash = head
1760 headCommit.Subject = "unknown"
1761 commits = append(commits, headCommit)
1762 }
1763
Philip Zeyligerf2872992025-05-22 10:35:28 -07001764 originalBranch := cmp.Or(ags.branchName, "sketch/"+sessionID)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001765 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001766
1767 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1768 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1769 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001770
1771 // Try up to 10 times with different branch names if the branch is checked out on the remote
1772 var out []byte
1773 var err error
1774 for retries := range 10 {
1775 if retries > 0 {
1776 // Add a numeric suffix to the branch name
1777 branch = fmt.Sprintf("%s%d", originalBranch, retries)
1778 }
1779
Philip Zeyligerf2872992025-05-22 10:35:28 -07001780 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1781 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001782 out, err = cmd.CombinedOutput()
1783
1784 if err == nil {
1785 // Success! Break out of the retry loop
1786 break
1787 }
1788
1789 // Check if this is the "refusing to update checked out branch" error
1790 if !strings.Contains(string(out), "refusing to update checked out branch") {
1791 // This is a different error, so don't retry
1792 break
1793 }
1794
1795 // If we're on the last retry, we'll report the error
1796 if retries == 9 {
1797 break
1798 }
1799 }
1800
1801 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001802 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001803 } else {
1804 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001805 // Update the agent's branch name if we ended up using a different one
1806 if branch != originalBranch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001807 ags.branchName = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001808 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001809 }
1810 }
1811
1812 // If we found new commits, create a message
1813 if len(commits) > 0 {
1814 msg := AgentMessage{
1815 Type: CommitMessageType,
1816 Timestamp: time.Now(),
1817 Commits: commits,
1818 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001819 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001820 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001821 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001822}
1823
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001824func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001825 return strings.Map(func(r rune) rune {
1826 // lowercase
1827 if r >= 'A' && r <= 'Z' {
1828 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001829 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001830 // replace spaces with dashes
1831 if r == ' ' {
1832 return '-'
1833 }
1834 // allow alphanumerics and dashes
1835 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1836 return r
1837 }
1838 return -1
1839 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001840}
1841
1842// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1843// and returns an array of GitCommit structs.
1844func parseGitLog(output string) []GitCommit {
1845 var commits []GitCommit
1846
1847 // No output means no commits
1848 if len(output) == 0 {
1849 return commits
1850 }
1851
1852 // Split by NULL byte
1853 parts := strings.Split(output, "\x00")
1854
1855 // Process in triplets (hash, subject, body)
1856 for i := 0; i < len(parts); i++ {
1857 // Skip empty parts
1858 if parts[i] == "" {
1859 continue
1860 }
1861
1862 // This should be a hash
1863 hash := strings.TrimSpace(parts[i])
1864
1865 // Make sure we have at least a subject part available
1866 if i+1 >= len(parts) {
1867 break // No more parts available
1868 }
1869
1870 // Get the subject
1871 subject := strings.TrimSpace(parts[i+1])
1872
1873 // Get the body if available
1874 body := ""
1875 if i+2 < len(parts) {
1876 body = strings.TrimSpace(parts[i+2])
1877 }
1878
1879 // Skip to the next triplet
1880 i += 2
1881
1882 commits = append(commits, GitCommit{
1883 Hash: hash,
1884 Subject: subject,
1885 Body: body,
1886 })
1887 }
1888
1889 return commits
1890}
1891
1892func repoRoot(ctx context.Context, dir string) (string, error) {
1893 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
1894 stderr := new(strings.Builder)
1895 cmd.Stderr = stderr
1896 cmd.Dir = dir
1897 out, err := cmd.Output()
1898 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001899 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07001900 }
1901 return strings.TrimSpace(string(out)), nil
1902}
1903
1904func resolveRef(ctx context.Context, dir, refName string) (string, error) {
1905 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
1906 stderr := new(strings.Builder)
1907 cmd.Stderr = stderr
1908 cmd.Dir = dir
1909 out, err := cmd.Output()
1910 if err != nil {
1911 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
1912 }
1913 // TODO: validate that out is valid hex
1914 return strings.TrimSpace(string(out)), nil
1915}
1916
1917// isValidGitSHA validates if a string looks like a valid git SHA hash.
1918// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
1919func isValidGitSHA(sha string) bool {
1920 // Git SHA must be a hexadecimal string with at least 4 characters
1921 if len(sha) < 4 || len(sha) > 40 {
1922 return false
1923 }
1924
1925 // Check if the string only contains hexadecimal characters
1926 for _, char := range sha {
1927 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
1928 return false
1929 }
1930 }
1931
1932 return true
1933}
Philip Zeyligerd1402952025-04-23 03:54:37 +00001934
1935// getGitOrigin returns the URL of the git remote 'origin' if it exists
1936func getGitOrigin(ctx context.Context, dir string) string {
1937 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
1938 cmd.Dir = dir
1939 stderr := new(strings.Builder)
1940 cmd.Stderr = stderr
1941 out, err := cmd.Output()
1942 if err != nil {
1943 return ""
1944 }
1945 return strings.TrimSpace(string(out))
1946}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001947
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001948// systemPromptData contains the data used to render the system prompt template
1949type systemPromptData struct {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001950 ClientGOOS string
1951 ClientGOARCH string
1952 WorkingDir string
1953 RepoRoot string
1954 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001955 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001956}
1957
1958// renderSystemPrompt renders the system prompt template.
1959func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001960 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001961 ClientGOOS: a.config.ClientGOOS,
1962 ClientGOARCH: a.config.ClientGOARCH,
1963 WorkingDir: a.workingDir,
1964 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07001965 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001966 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001967 }
1968
1969 tmpl, err := template.New("system").Parse(agentSystemPrompt)
1970 if err != nil {
1971 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
1972 }
1973 buf := new(strings.Builder)
1974 err = tmpl.Execute(buf, data)
1975 if err != nil {
1976 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
1977 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001978 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001979 return buf.String()
1980}
Philip Zeyligereab12de2025-05-14 02:35:53 +00001981
1982// StateTransitionIterator provides an iterator over state transitions.
1983type StateTransitionIterator interface {
1984 // Next blocks until a new state transition is available or context is done.
1985 // Returns nil if the context is cancelled.
1986 Next() *StateTransition
1987 // Close removes the listener and cleans up resources.
1988 Close()
1989}
1990
1991// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
1992type StateTransitionIteratorImpl struct {
1993 agent *Agent
1994 ctx context.Context
1995 ch chan StateTransition
1996 unsubscribe func()
1997}
1998
1999// Next blocks until a new state transition is available or the context is cancelled.
2000func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2001 select {
2002 case <-s.ctx.Done():
2003 return nil
2004 case transition, ok := <-s.ch:
2005 if !ok {
2006 return nil
2007 }
2008 transitionCopy := transition
2009 return &transitionCopy
2010 }
2011}
2012
2013// Close removes the listener and cleans up resources.
2014func (s *StateTransitionIteratorImpl) Close() {
2015 if s.unsubscribe != nil {
2016 s.unsubscribe()
2017 s.unsubscribe = nil
2018 }
2019}
2020
2021// NewStateTransitionIterator returns an iterator that receives state transitions.
2022func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2023 a.mu.Lock()
2024 defer a.mu.Unlock()
2025
2026 // Create channel to receive state transitions
2027 ch := make(chan StateTransition, 10)
2028
2029 // Add a listener to the state machine
2030 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2031
2032 return &StateTransitionIteratorImpl{
2033 agent: a,
2034 ctx: ctx,
2035 ch: ch,
2036 unsubscribe: unsubscribe,
2037 }
2038}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002039
2040// setupGitHooks creates or updates git hooks in the specified working directory.
2041func setupGitHooks(workingDir string) error {
2042 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2043
2044 _, err := os.Stat(hooksDir)
2045 if os.IsNotExist(err) {
2046 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2047 }
2048 if err != nil {
2049 return fmt.Errorf("error checking git hooks directory: %w", err)
2050 }
2051
2052 // Define the post-commit hook content
2053 postCommitHook := `#!/bin/bash
2054echo "<post_commit_hook>"
2055echo "Please review this commit message and fix it if it is incorrect."
2056echo "This hook only echos the commit message; it does not modify it."
2057echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2058echo "<last_commit_message>"
2059git log -1 --pretty=%B
2060echo "</last_commit_message>"
2061echo "</post_commit_hook>"
2062`
2063
2064 // Define the prepare-commit-msg hook content
2065 prepareCommitMsgHook := `#!/bin/bash
2066# Add Co-Authored-By and Change-ID trailers to commit messages
2067# Check if these trailers already exist before adding them
2068
2069commit_file="$1"
2070COMMIT_SOURCE="$2"
2071
2072# Skip for merges, squashes, or when using a commit template
2073if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2074 [ "$COMMIT_SOURCE" = "squash" ]; then
2075 exit 0
2076fi
2077
2078commit_msg=$(cat "$commit_file")
2079
2080needs_co_author=true
2081needs_change_id=true
2082
2083# Check if commit message already has Co-Authored-By trailer
2084if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2085 needs_co_author=false
2086fi
2087
2088# Check if commit message already has Change-ID trailer
2089if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2090 needs_change_id=false
2091fi
2092
2093# Only modify if at least one trailer needs to be added
2094if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002095 # Ensure there's a proper blank line before trailers
2096 if [ -s "$commit_file" ]; then
2097 # Check if file ends with newline by reading last character
2098 last_char=$(tail -c 1 "$commit_file")
2099
2100 if [ "$last_char" != "" ]; then
2101 # File doesn't end with newline - add two newlines (complete line + blank line)
2102 echo "" >> "$commit_file"
2103 echo "" >> "$commit_file"
2104 else
2105 # File ends with newline - check if we already have a blank line
2106 last_line=$(tail -1 "$commit_file")
2107 if [ -n "$last_line" ]; then
2108 # Last line has content - add one newline for blank line
2109 echo "" >> "$commit_file"
2110 fi
2111 # If last line is empty, we already have a blank line - don't add anything
2112 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002113 fi
2114
2115 # Add trailers if needed
2116 if [ "$needs_co_author" = true ]; then
2117 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2118 fi
2119
2120 if [ "$needs_change_id" = true ]; then
2121 change_id=$(openssl rand -hex 8)
2122 echo "Change-ID: s${change_id}k" >> "$commit_file"
2123 fi
2124fi
2125`
2126
2127 // Update or create the post-commit hook
2128 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2129 if err != nil {
2130 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2131 }
2132
2133 // Update or create the prepare-commit-msg hook
2134 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2135 if err != nil {
2136 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2137 }
2138
2139 return nil
2140}
2141
2142// updateOrCreateHook creates a new hook file or updates an existing one
2143// by appending the new content if it doesn't already contain it.
2144func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2145 // Check if the hook already exists
2146 buf, err := os.ReadFile(hookPath)
2147 if os.IsNotExist(err) {
2148 // Hook doesn't exist, create it
2149 err = os.WriteFile(hookPath, []byte(content), 0o755)
2150 if err != nil {
2151 return fmt.Errorf("failed to create hook: %w", err)
2152 }
2153 return nil
2154 }
2155 if err != nil {
2156 return fmt.Errorf("error reading existing hook: %w", err)
2157 }
2158
2159 // Hook exists, check if our content is already in it by looking for a distinctive line
2160 code := string(buf)
2161 if strings.Contains(code, distinctiveLine) {
2162 // Already contains our content, nothing to do
2163 return nil
2164 }
2165
2166 // Append our content to the existing hook
2167 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2168 if err != nil {
2169 return fmt.Errorf("failed to open hook for appending: %w", err)
2170 }
2171 defer f.Close()
2172
2173 // Ensure there's a newline at the end of the existing content if needed
2174 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2175 _, err = f.WriteString("\n")
2176 if err != nil {
2177 return fmt.Errorf("failed to add newline to hook: %w", err)
2178 }
2179 }
2180
2181 // Add a separator before our content
2182 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2183 if err != nil {
2184 return fmt.Errorf("failed to append to hook: %w", err)
2185 }
2186
2187 return nil
2188}