blob: 33269422910a0b91cb03c8cd6f2ff5f1e160e56e [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"
Philip Zeyliger59e1c162025-06-02 12:54:34 +000015 "regexp"
Earl Lee2e463fb2025-04-17 11:22:22 -070016 "runtime/debug"
17 "slices"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070018 "strconv"
Earl Lee2e463fb2025-04-17 11:22:22 -070019 "strings"
20 "sync"
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +000021 "text/template"
Earl Lee2e463fb2025-04-17 11:22:22 -070022 "time"
23
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000024 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070025 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000026 "sketch.dev/claudetool/bashkit"
Autoformatter4962f152025-05-06 17:24:20 +000027 "sketch.dev/claudetool/browse"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000028 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydera997be62025-05-07 22:52:46 +000029 "sketch.dev/claudetool/onstart"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070030 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070031 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070032 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070033)
34
35const (
36 userCancelMessage = "user requested agent to stop handling responses"
37)
38
Philip Zeyligerb5739402025-06-02 07:04:34 -070039// EndFeedback represents user feedback when ending a session
40type EndFeedback struct {
41 Happy bool `json:"happy"`
42 Comment string `json:"comment"`
43}
44
Philip Zeyligerb7c58752025-05-01 10:10:17 -070045type MessageIterator interface {
46 // Next blocks until the next message is available. It may
47 // return nil if the underlying iterator context is done.
48 Next() *AgentMessage
49 Close()
50}
51
Earl Lee2e463fb2025-04-17 11:22:22 -070052type CodingAgent interface {
53 // Init initializes an agent inside a docker container.
54 Init(AgentInit) error
55
56 // Ready returns a channel closed after Init successfully called.
57 Ready() <-chan struct{}
58
59 // URL reports the HTTP URL of this agent.
60 URL() string
61
62 // UserMessage enqueues a message to the agent and returns immediately.
63 UserMessage(ctx context.Context, msg string)
64
Philip Zeyligerb7c58752025-05-01 10:10:17 -070065 // Returns an iterator that finishes when the context is done and
66 // starts with the given message index.
67 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070068
Philip Zeyligereab12de2025-05-14 02:35:53 +000069 // Returns an iterator that notifies of state transitions until the context is done.
70 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
71
Earl Lee2e463fb2025-04-17 11:22:22 -070072 // Loop begins the agent loop returns only when ctx is cancelled.
73 Loop(ctx context.Context)
74
Sean McCulloughedc88dc2025-04-30 02:55:01 +000075 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070076
77 CancelToolUse(toolUseID string, cause error) error
78
79 // Returns a subset of the agent's message history.
80 Messages(start int, end int) []AgentMessage
81
82 // Returns the current number of messages in the history
83 MessageCount() int
84
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070085 TotalUsage() conversation.CumulativeUsage
86 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070087
Earl Lee2e463fb2025-04-17 11:22:22 -070088 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000089 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070090
91 // Diff returns a unified diff of changes made since the agent was instantiated.
92 // If commit is non-nil, it shows the diff for just that specific commit.
93 Diff(commit *string) (string, error)
94
Philip Zeyliger49edc922025-05-14 09:45:45 -070095 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
96 // starts out as the commit where sketch started, but a user can move it if need
97 // be, for example in the case of a rebase. It is stored as a git tag.
98 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070099
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000100 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
101 // (Typically, this is "sketch-base")
102 SketchGitBaseRef() string
103
Earl Lee2e463fb2025-04-17 11:22:22 -0700104 // Title returns the current title of the conversation.
105 Title() string
106
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000107 // BranchName returns the git branch name for the conversation.
108 BranchName() string
109
Earl Lee2e463fb2025-04-17 11:22:22 -0700110 // OS returns the operating system of the client.
111 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000112
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000113 // SessionID returns the unique session identifier.
114 SessionID() string
115
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000116 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700117 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000118
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000119 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
120 OutstandingLLMCallCount() int
121
122 // OutstandingToolCalls returns the names of outstanding tool calls.
123 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000124 OutsideOS() string
125 OutsideHostname() string
126 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000127 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000128 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
129 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700130
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700131 // IsInContainer returns true if the agent is running in a container
132 IsInContainer() bool
133 // FirstMessageIndex returns the index of the first message in the current conversation
134 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700135
136 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700137 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
138 CurrentTodoContent() string
Philip Zeyligerb5739402025-06-02 07:04:34 -0700139 // GetEndFeedback returns the end session feedback
140 GetEndFeedback() *EndFeedback
141 // SetEndFeedback sets the end session feedback
142 SetEndFeedback(feedback *EndFeedback)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700143
144 // CompactConversation compacts the current conversation by generating a summary
145 // and restarting the conversation with that summary as the initial context
146 CompactConversation(ctx context.Context) error
Sean McCullough138ec242025-06-02 22:42:06 +0000147 // GetPortMonitor returns the port monitor instance for accessing port events
148 GetPortMonitor() *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700149}
150
151type CodingAgentMessageType string
152
153const (
154 UserMessageType CodingAgentMessageType = "user"
155 AgentMessageType CodingAgentMessageType = "agent"
156 ErrorMessageType CodingAgentMessageType = "error"
157 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
158 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700159 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
160 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
161 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700162
163 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
164)
165
166type AgentMessage struct {
167 Type CodingAgentMessageType `json:"type"`
168 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
169 EndOfTurn bool `json:"end_of_turn"`
170
171 Content string `json:"content"`
172 ToolName string `json:"tool_name,omitempty"`
173 ToolInput string `json:"input,omitempty"`
174 ToolResult string `json:"tool_result,omitempty"`
175 ToolError bool `json:"tool_error,omitempty"`
176 ToolCallId string `json:"tool_call_id,omitempty"`
177
178 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
179 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
180
Sean McCulloughd9f13372025-04-21 15:08:49 -0700181 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
182 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
183
Earl Lee2e463fb2025-04-17 11:22:22 -0700184 // Commits is a list of git commits for a commit message
185 Commits []*GitCommit `json:"commits,omitempty"`
186
187 Timestamp time.Time `json:"timestamp"`
188 ConversationID string `json:"conversation_id"`
189 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700190 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700191
192 // Message timing information
193 StartTime *time.Time `json:"start_time,omitempty"`
194 EndTime *time.Time `json:"end_time,omitempty"`
195 Elapsed *time.Duration `json:"elapsed,omitempty"`
196
197 // Turn duration - the time taken for a complete agent turn
198 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
199
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000200 // HideOutput indicates that this message should not be rendered in the UI.
201 // This is useful for subconversations that generate output that shouldn't be shown to the user.
202 HideOutput bool `json:"hide_output,omitempty"`
203
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700204 // TodoContent contains the agent's todo file content when it has changed
205 TodoContent *string `json:"todo_content,omitempty"`
206
Earl Lee2e463fb2025-04-17 11:22:22 -0700207 Idx int `json:"idx"`
208}
209
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000210// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700211func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700212 if convo == nil {
213 m.ConversationID = ""
214 m.ParentConversationID = nil
215 return
216 }
217 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000218 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700219 if convo.Parent != nil {
220 m.ParentConversationID = &convo.Parent.ID
221 }
222}
223
Earl Lee2e463fb2025-04-17 11:22:22 -0700224// GitCommit represents a single git commit for a commit message
225type GitCommit struct {
226 Hash string `json:"hash"` // Full commit hash
227 Subject string `json:"subject"` // Commit subject line
228 Body string `json:"body"` // Full commit message body
229 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
230}
231
232// ToolCall represents a single tool call within an agent message
233type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700234 Name string `json:"name"`
235 Input string `json:"input"`
236 ToolCallId string `json:"tool_call_id"`
237 ResultMessage *AgentMessage `json:"result_message,omitempty"`
238 Args string `json:"args,omitempty"`
239 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700240}
241
242func (a *AgentMessage) Attr() slog.Attr {
243 var attrs []any = []any{
244 slog.String("type", string(a.Type)),
245 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700246 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700247 if a.EndOfTurn {
248 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
249 }
250 if a.Content != "" {
251 attrs = append(attrs, slog.String("content", a.Content))
252 }
253 if a.ToolName != "" {
254 attrs = append(attrs, slog.String("tool_name", a.ToolName))
255 }
256 if a.ToolInput != "" {
257 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
258 }
259 if a.Elapsed != nil {
260 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
261 }
262 if a.TurnDuration != nil {
263 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
264 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700265 if len(a.ToolResult) > 0 {
266 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700267 }
268 if a.ToolError {
269 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
270 }
271 if len(a.ToolCalls) > 0 {
272 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
273 for i, tc := range a.ToolCalls {
274 toolCallAttrs = append(toolCallAttrs, slog.Group(
275 fmt.Sprintf("tool_call_%d", i),
276 slog.String("name", tc.Name),
277 slog.String("input", tc.Input),
278 ))
279 }
280 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
281 }
282 if a.ConversationID != "" {
283 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
284 }
285 if a.ParentConversationID != nil {
286 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
287 }
288 if a.Usage != nil && !a.Usage.IsZero() {
289 attrs = append(attrs, a.Usage.Attr())
290 }
291 // TODO: timestamp, convo ids, idx?
292 return slog.Group("agent_message", attrs...)
293}
294
295func errorMessage(err error) AgentMessage {
296 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
297 if os.Getenv(("DEBUG")) == "1" {
298 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
299 }
300
301 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
302}
303
304func budgetMessage(err error) AgentMessage {
305 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
306}
307
308// ConvoInterface defines the interface for conversation interactions
309type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700310 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700311 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700312 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700313 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700314 SendMessage(message llm.Message) (*llm.Response, error)
315 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700316 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000317 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700318 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700319 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700320 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700321}
322
Philip Zeyligerf2872992025-05-22 10:35:28 -0700323// AgentGitState holds the state necessary for pushing to a remote git repo
324// when HEAD changes. If gitRemoteAddr is set, then we push to sketch/
325// any time we notice we need to.
326type AgentGitState struct {
327 mu sync.Mutex // protects following
328 lastHEAD string // hash of the last HEAD that was pushed to the host
329 gitRemoteAddr string // HTTP URL of the host git repo
330 seenCommits map[string]bool // Track git commits we've already seen (by hash)
331 branchName string
332}
333
334func (ags *AgentGitState) SetBranchName(branchName string) {
335 ags.mu.Lock()
336 defer ags.mu.Unlock()
337 ags.branchName = branchName
338}
339
340func (ags *AgentGitState) BranchName() string {
341 ags.mu.Lock()
342 defer ags.mu.Unlock()
343 return ags.branchName
344}
345
Earl Lee2e463fb2025-04-17 11:22:22 -0700346type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700347 convo ConvoInterface
348 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700349 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700350 workingDir string
351 repoRoot string // workingDir may be a subdir of repoRoot
352 url string
353 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000354 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700355 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000356 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700357 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700358 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700359 title string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000360 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700361 // State machine to track agent state
362 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000363 // Outside information
364 outsideHostname string
365 outsideOS string
366 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000367 // URL of the git remote 'origin' if it exists
368 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700369
370 // Time when the current turn started (reset at the beginning of InnerLoop)
371 startOfTurn time.Time
372
373 // Inbox - for messages from the user to the agent.
374 // sent on by UserMessage
375 // . e.g. when user types into the chat textarea
376 // read from by GatherMessages
377 inbox chan string
378
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000379 // protects cancelTurn
380 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700381 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000382 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700383
384 // protects following
385 mu sync.Mutex
386
387 // Stores all messages for this agent
388 history []AgentMessage
389
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700390 // Iterators add themselves here when they're ready to be notified of new messages.
391 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700392
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000393 // Track outstanding LLM call IDs
394 outstandingLLMCalls map[string]struct{}
395
396 // Track outstanding tool calls by ID with their names
397 outstandingToolCalls map[string]string
Sean McCullough364f7412025-06-02 00:55:44 +0000398
399 // Port monitoring
400 portMonitor *PortMonitor
Philip Zeyligerb5739402025-06-02 07:04:34 -0700401
402 // End session feedback
403 endFeedback *EndFeedback
Earl Lee2e463fb2025-04-17 11:22:22 -0700404}
405
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700406// NewIterator implements CodingAgent.
407func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
408 a.mu.Lock()
409 defer a.mu.Unlock()
410
411 return &MessageIteratorImpl{
412 agent: a,
413 ctx: ctx,
414 nextMessageIdx: nextMessageIdx,
415 ch: make(chan *AgentMessage, 100),
416 }
417}
418
419type MessageIteratorImpl struct {
420 agent *Agent
421 ctx context.Context
422 nextMessageIdx int
423 ch chan *AgentMessage
424 subscribed bool
425}
426
427func (m *MessageIteratorImpl) Close() {
428 m.agent.mu.Lock()
429 defer m.agent.mu.Unlock()
430 // Delete ourselves from the subscribers list
431 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
432 return x == m.ch
433 })
434 close(m.ch)
435}
436
437func (m *MessageIteratorImpl) Next() *AgentMessage {
438 // We avoid subscription at creation to let ourselves catch up to "current state"
439 // before subscribing.
440 if !m.subscribed {
441 m.agent.mu.Lock()
442 if m.nextMessageIdx < len(m.agent.history) {
443 msg := &m.agent.history[m.nextMessageIdx]
444 m.nextMessageIdx++
445 m.agent.mu.Unlock()
446 return msg
447 }
448 // The next message doesn't exist yet, so let's subscribe
449 m.agent.subscribers = append(m.agent.subscribers, m.ch)
450 m.subscribed = true
451 m.agent.mu.Unlock()
452 }
453
454 for {
455 select {
456 case <-m.ctx.Done():
457 m.agent.mu.Lock()
458 // Delete ourselves from the subscribers list
459 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
460 return x == m.ch
461 })
462 m.subscribed = false
463 m.agent.mu.Unlock()
464 return nil
465 case msg, ok := <-m.ch:
466 if !ok {
467 // Close may have been called
468 return nil
469 }
470 if msg.Idx == m.nextMessageIdx {
471 m.nextMessageIdx++
472 return msg
473 }
474 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
475 panic("out of order message")
476 }
477 }
478}
479
Sean McCulloughd9d45812025-04-30 16:53:41 -0700480// Assert that Agent satisfies the CodingAgent interface.
481var _ CodingAgent = &Agent{}
482
483// StateName implements CodingAgent.
484func (a *Agent) CurrentStateName() string {
485 if a.stateMachine == nil {
486 return ""
487 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000488 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700489}
490
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700491// CurrentTodoContent returns the current todo list data as JSON.
492// It returns an empty string if no todos exist.
493func (a *Agent) CurrentTodoContent() string {
494 todoPath := claudetool.TodoFilePath(a.config.SessionID)
495 content, err := os.ReadFile(todoPath)
496 if err != nil {
497 return ""
498 }
499 return string(content)
500}
501
Philip Zeyligerb5739402025-06-02 07:04:34 -0700502// SetEndFeedback sets the end session feedback
503func (a *Agent) SetEndFeedback(feedback *EndFeedback) {
504 a.mu.Lock()
505 defer a.mu.Unlock()
506 a.endFeedback = feedback
507}
508
509// GetEndFeedback gets the end session feedback
510func (a *Agent) GetEndFeedback() *EndFeedback {
511 a.mu.Lock()
512 defer a.mu.Unlock()
513 return a.endFeedback
514}
515
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700516// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
517func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
518 msg := `You are being asked to create a comprehensive summary of our conversation so far. This summary will be used to restart our conversation with a shorter history while preserving all important context.
519
520IMPORTANT: Focus ONLY on the actual conversation with the user. Do NOT include any information from system prompts, tool descriptions, or general instructions. Only summarize what the user asked for and what we accomplished together.
521
522Please create a detailed summary that includes:
523
5241. **User's Request**: What did the user originally ask me to do? What was their goal?
525
5262. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
527
5283. **Key Technical Decisions**: What important technical choices were made during our work and why?
529
5304. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
531
5325. **Next Steps**: What still needs to be done to complete the user's request?
533
5346. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
535
536Focus on actionable information that would help me continue the user's work seamlessly. Ignore any general tool capabilities or system instructions - only include what's relevant to this specific user's project and goals.
537
538Reply with ONLY the summary content - no meta-commentary about creating the summary.`
539
540 userMessage := llm.UserStringMessage(msg)
541 // Use a subconversation with history to get the summary
542 // TODO: We don't have any tools here, so we should have enough tokens
543 // to capture a summary, but we may need to modify the history (e.g., remove
544 // TODO data) to save on some tokens.
545 convo := a.convo.SubConvoWithHistory()
546
547 // Modify the system prompt to provide context about the original task
548 originalSystemPrompt := convo.SystemPrompt
549 convo.SystemPrompt = fmt.Sprintf(`You are creating a conversation summary for context compaction. The original system prompt contained instructions about being a software engineer and architect for Sketch (an agentic coding environment), with various tools and capabilities for code analysis, file modification, git operations, browser automation, and project management.
550
551Your task is to create a focused summary as requested below. Focus only on the actual user conversation and work accomplished, not the system capabilities or tool descriptions.
552
553Original context: You are working in a coding environment with full access to development tools.`)
554
555 resp, err := convo.SendMessage(userMessage)
556 if err != nil {
557 a.pushToOutbox(ctx, errorMessage(err))
558 return "", err
559 }
560 textContent := collectTextContent(resp)
561
562 // Restore original system prompt (though this subconvo will be discarded)
563 convo.SystemPrompt = originalSystemPrompt
564
565 return textContent, nil
566}
567
568// CompactConversation compacts the current conversation by generating a summary
569// and restarting the conversation with that summary as the initial context
570func (a *Agent) CompactConversation(ctx context.Context) error {
571 summary, err := a.generateConversationSummary(ctx)
572 if err != nil {
573 return fmt.Errorf("failed to generate conversation summary: %w", err)
574 }
575
576 a.mu.Lock()
577
578 // Get usage information before resetting conversation
579 lastUsage := a.convo.LastUsage()
580 contextWindow := a.config.Service.TokenContextWindow()
581 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
582
583 // Reset conversation state but keep all other state (git, working dir, etc.)
584 a.firstMessageIndex = len(a.history)
585 a.convo = a.initConvo()
586
587 a.mu.Unlock()
588
589 // Create informative compaction message with token details
590 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
591 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
592 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
593
594 a.pushToOutbox(ctx, AgentMessage{
595 Type: CompactMessageType,
596 Content: compactionMsg,
597 })
598
599 a.pushToOutbox(ctx, AgentMessage{
600 Type: UserMessageType,
601 Content: fmt.Sprintf("Here's a summary of our previous work:\n\n%s\n\nPlease continue with the work based on this summary.", summary),
602 })
603 a.inbox <- fmt.Sprintf("Here's a summary of our previous work:\n\n%s\n\nPlease continue with the work based on this summary.", summary)
604
605 return nil
606}
607
Earl Lee2e463fb2025-04-17 11:22:22 -0700608func (a *Agent) URL() string { return a.url }
609
610// Title returns the current title of the conversation.
611// If no title has been set, returns an empty string.
612func (a *Agent) Title() string {
613 a.mu.Lock()
614 defer a.mu.Unlock()
615 return a.title
616}
617
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000618// BranchName returns the git branch name for the conversation.
619func (a *Agent) BranchName() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700620 return a.gitState.BranchName()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000621}
622
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000623// OutstandingLLMCallCount returns the number of outstanding LLM calls.
624func (a *Agent) OutstandingLLMCallCount() int {
625 a.mu.Lock()
626 defer a.mu.Unlock()
627 return len(a.outstandingLLMCalls)
628}
629
630// OutstandingToolCalls returns the names of outstanding tool calls.
631func (a *Agent) OutstandingToolCalls() []string {
632 a.mu.Lock()
633 defer a.mu.Unlock()
634
635 tools := make([]string, 0, len(a.outstandingToolCalls))
636 for _, toolName := range a.outstandingToolCalls {
637 tools = append(tools, toolName)
638 }
639 return tools
640}
641
Earl Lee2e463fb2025-04-17 11:22:22 -0700642// OS returns the operating system of the client.
643func (a *Agent) OS() string {
644 return a.config.ClientGOOS
645}
646
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000647func (a *Agent) SessionID() string {
648 return a.config.SessionID
649}
650
Philip Zeyliger18532b22025-04-23 21:11:46 +0000651// OutsideOS returns the operating system of the outside system.
652func (a *Agent) OutsideOS() string {
653 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000654}
655
Philip Zeyliger18532b22025-04-23 21:11:46 +0000656// OutsideHostname returns the hostname of the outside system.
657func (a *Agent) OutsideHostname() string {
658 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000659}
660
Philip Zeyliger18532b22025-04-23 21:11:46 +0000661// OutsideWorkingDir returns the working directory on the outside system.
662func (a *Agent) OutsideWorkingDir() string {
663 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000664}
665
666// GitOrigin returns the URL of the git remote 'origin' if it exists.
667func (a *Agent) GitOrigin() string {
668 return a.gitOrigin
669}
670
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000671func (a *Agent) OpenBrowser(url string) {
672 if !a.IsInContainer() {
673 browser.Open(url)
674 return
675 }
676 // We're in Docker, need to send a request to the Git server
677 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700678 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000679 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700680 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000681 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700682 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000683 return
684 }
685 defer resp.Body.Close()
686 if resp.StatusCode == http.StatusOK {
687 return
688 }
689 body, _ := io.ReadAll(resp.Body)
690 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
691}
692
Sean McCullough96b60dd2025-04-30 09:49:10 -0700693// CurrentState returns the current state of the agent's state machine.
694func (a *Agent) CurrentState() State {
695 return a.stateMachine.CurrentState()
696}
697
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700698func (a *Agent) IsInContainer() bool {
699 return a.config.InDocker
700}
701
702func (a *Agent) FirstMessageIndex() int {
703 a.mu.Lock()
704 defer a.mu.Unlock()
705 return a.firstMessageIndex
706}
707
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000708// SetTitle sets the title of the conversation.
709func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700710 a.mu.Lock()
711 defer a.mu.Unlock()
712 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000713}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700714
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000715// SetBranch sets the branch name of the conversation.
716func (a *Agent) SetBranch(branchName string) {
717 a.mu.Lock()
718 defer a.mu.Unlock()
Philip Zeyligerf2872992025-05-22 10:35:28 -0700719 a.gitState.SetBranchName(branchName)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000720 convo, ok := a.convo.(*conversation.Convo)
721 if ok {
722 convo.ExtraData["branch"] = branchName
723 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700724}
725
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000726// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700727func (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 +0000728 // Track the tool call
729 a.mu.Lock()
730 a.outstandingToolCalls[id] = toolName
731 a.mu.Unlock()
732}
733
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700734// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
735// If there's only one element in the array and it's a text type, it returns that text directly.
736// It also processes nested ToolResult arrays recursively.
737func contentToString(contents []llm.Content) string {
738 if len(contents) == 0 {
739 return ""
740 }
741
742 // If there's only one element and it's a text type, return it directly
743 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
744 return contents[0].Text
745 }
746
747 // Otherwise, concatenate all text content
748 var result strings.Builder
749 for _, content := range contents {
750 if content.Type == llm.ContentTypeText {
751 result.WriteString(content.Text)
752 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
753 // Recursively process nested tool results
754 result.WriteString(contentToString(content.ToolResult))
755 }
756 }
757
758 return result.String()
759}
760
Earl Lee2e463fb2025-04-17 11:22:22 -0700761// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700762func (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 +0000763 // Remove the tool call from outstanding calls
764 a.mu.Lock()
765 delete(a.outstandingToolCalls, toolID)
766 a.mu.Unlock()
767
Earl Lee2e463fb2025-04-17 11:22:22 -0700768 m := AgentMessage{
769 Type: ToolUseMessageType,
770 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700771 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700772 ToolError: content.ToolError,
773 ToolName: toolName,
774 ToolInput: string(toolInput),
775 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700776 StartTime: content.ToolUseStartTime,
777 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700778 }
779
780 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700781 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
782 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700783 m.Elapsed = &elapsed
784 }
785
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700786 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700787 a.pushToOutbox(ctx, m)
788}
789
790// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700791func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000792 a.mu.Lock()
793 defer a.mu.Unlock()
794 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700795 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
796}
797
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700798// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700799// that need to be displayed (as well as tool calls that we send along when
800// they're done). (It would be reasonable to also mention tool calls when they're
801// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700802func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000803 // Remove the LLM call from outstanding calls
804 a.mu.Lock()
805 delete(a.outstandingLLMCalls, id)
806 a.mu.Unlock()
807
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700808 if resp == nil {
809 // LLM API call failed
810 m := AgentMessage{
811 Type: ErrorMessageType,
812 Content: "API call failed, type 'continue' to try again",
813 }
814 m.SetConvo(convo)
815 a.pushToOutbox(ctx, m)
816 return
817 }
818
Earl Lee2e463fb2025-04-17 11:22:22 -0700819 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700820 if convo.Parent == nil { // subconvos never end the turn
821 switch resp.StopReason {
822 case llm.StopReasonToolUse:
823 // Check whether any of the tool calls are for tools that should end the turn
824 ToolSearch:
825 for _, part := range resp.Content {
826 if part.Type != llm.ContentTypeToolUse {
827 continue
828 }
Sean McCullough021557a2025-05-05 23:20:53 +0000829 // Find the tool by name
830 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700831 if tool.Name == part.ToolName {
832 endOfTurn = tool.EndsTurn
833 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000834 }
835 }
Sean McCullough021557a2025-05-05 23:20:53 +0000836 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700837 default:
838 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000839 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700840 }
841 m := AgentMessage{
842 Type: AgentMessageType,
843 Content: collectTextContent(resp),
844 EndOfTurn: endOfTurn,
845 Usage: &resp.Usage,
846 StartTime: resp.StartTime,
847 EndTime: resp.EndTime,
848 }
849
850 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700851 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700852 var toolCalls []ToolCall
853 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700854 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700855 toolCalls = append(toolCalls, ToolCall{
856 Name: part.ToolName,
857 Input: string(part.ToolInput),
858 ToolCallId: part.ID,
859 })
860 }
861 }
862 m.ToolCalls = toolCalls
863 }
864
865 // Calculate the elapsed time if both start and end times are set
866 if resp.StartTime != nil && resp.EndTime != nil {
867 elapsed := resp.EndTime.Sub(*resp.StartTime)
868 m.Elapsed = &elapsed
869 }
870
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700871 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700872 a.pushToOutbox(ctx, m)
873}
874
875// WorkingDir implements CodingAgent.
876func (a *Agent) WorkingDir() string {
877 return a.workingDir
878}
879
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000880// RepoRoot returns the git repository root directory.
881func (a *Agent) RepoRoot() string {
882 return a.repoRoot
883}
884
Earl Lee2e463fb2025-04-17 11:22:22 -0700885// MessageCount implements CodingAgent.
886func (a *Agent) MessageCount() int {
887 a.mu.Lock()
888 defer a.mu.Unlock()
889 return len(a.history)
890}
891
892// Messages implements CodingAgent.
893func (a *Agent) Messages(start int, end int) []AgentMessage {
894 a.mu.Lock()
895 defer a.mu.Unlock()
896 return slices.Clone(a.history[start:end])
897}
898
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700899// ShouldCompact checks if the conversation should be compacted based on token usage
900func (a *Agent) ShouldCompact() bool {
901 // Get the threshold from environment variable, default to 0.94 (94%)
902 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
903 // and a little bit of buffer.)
904 thresholdRatio := 0.94
905 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
906 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
907 thresholdRatio = parsed
908 }
909 }
910
911 // Get the most recent usage to check current context size
912 lastUsage := a.convo.LastUsage()
913
914 if lastUsage.InputTokens == 0 {
915 // No API calls made yet
916 return false
917 }
918
919 // Calculate the current context size from the last API call
920 // This includes all tokens that were part of the input context:
921 // - Input tokens (user messages, system prompt, conversation history)
922 // - Cache read tokens (cached parts of the context)
923 // - Cache creation tokens (new parts being cached)
924 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
925
926 // Get the service's token context window
927 service := a.config.Service
928 contextWindow := service.TokenContextWindow()
929
930 // Calculate threshold
931 threshold := uint64(float64(contextWindow) * thresholdRatio)
932
933 // Check if we've exceeded the threshold
934 return currentContextSize >= threshold
935}
936
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700937func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700938 return a.originalBudget
939}
940
941// AgentConfig contains configuration for creating a new Agent.
942type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +0000943 Context context.Context
944 Service llm.Service
945 Budget conversation.Budget
946 GitUsername string
947 GitEmail string
948 SessionID string
949 ClientGOOS string
950 ClientGOARCH string
951 InDocker bool
952 OneShot bool
953 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000954 // Outside information
955 OutsideHostname string
956 OutsideOS string
957 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700958
959 // Outtie's HTTP to, e.g., open a browser
960 OutsideHTTP string
961 // Outtie's Git server
962 GitRemoteAddr string
963 // Commit to checkout from Outtie
964 Commit string
Earl Lee2e463fb2025-04-17 11:22:22 -0700965}
966
967// NewAgent creates a new Agent.
968// It is not usable until Init() is called.
969func NewAgent(config AgentConfig) *Agent {
970 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -0700971 config: config,
972 ready: make(chan struct{}),
973 inbox: make(chan string, 100),
974 subscribers: make([]chan *AgentMessage, 0),
975 startedAt: time.Now(),
976 originalBudget: config.Budget,
977 gitState: AgentGitState{
978 seenCommits: make(map[string]bool),
979 gitRemoteAddr: config.GitRemoteAddr,
980 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000981 outsideHostname: config.OutsideHostname,
982 outsideOS: config.OutsideOS,
983 outsideWorkingDir: config.OutsideWorkingDir,
984 outstandingLLMCalls: make(map[string]struct{}),
985 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700986 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700987 workingDir: config.WorkingDir,
988 outsideHTTP: config.OutsideHTTP,
Sean McCullough364f7412025-06-02 00:55:44 +0000989 portMonitor: NewPortMonitor(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700990 }
991 return agent
992}
993
994type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700995 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -0700996
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700997 InDocker bool
998 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -0700999}
1000
1001func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001002 if a.convo != nil {
1003 return fmt.Errorf("Agent.Init: already initialized")
1004 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001005 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001006 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001007
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001008 if !ini.NoGit {
1009 // Capture the original origin before we potentially replace it below
1010 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
1011 }
1012
Philip Zeyliger222bf412025-06-04 16:42:58 +00001013 // If a remote git addr was specified, we configure the origin remote
Philip Zeyligerf2872992025-05-22 10:35:28 -07001014 if a.gitState.gitRemoteAddr != "" {
1015 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
Philip Zeyliger222bf412025-06-04 16:42:58 +00001016
1017 // Remove existing origin remote if it exists
1018 cmd := exec.CommandContext(ctx, "git", "remote", "remove", "origin")
Philip Zeyligerf2872992025-05-22 10:35:28 -07001019 cmd.Dir = a.workingDir
1020 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001021 // Ignore error if origin doesn't exist
1022 slog.DebugContext(ctx, "git remote remove origin (ignoring if not exists)", slog.String("output", string(out)))
Philip Zeyligerf2872992025-05-22 10:35:28 -07001023 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001024
1025 // Add the new remote as origin
1026 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", a.gitState.gitRemoteAddr)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001027 cmd.Dir = a.workingDir
1028 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001029 return fmt.Errorf("git remote add origin: %s: %v", out, err)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001030 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001031
Philip Zeyligerf2872992025-05-22 10:35:28 -07001032 }
1033
1034 // If a commit was specified, we fetch and reset to it.
1035 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001036 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1037
Earl Lee2e463fb2025-04-17 11:22:22 -07001038 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001039 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001040 if out, err := cmd.CombinedOutput(); err != nil {
1041 return fmt.Errorf("git stash: %s: %v", out, err)
1042 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001043 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001044 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001045 if out, err := cmd.CombinedOutput(); err != nil {
1046 return fmt.Errorf("git fetch: %s: %w", out, err)
1047 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001048 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1049 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001050 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1051 // Remove git hooks if they exist and retry
1052 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001053 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001054 if _, statErr := os.Stat(hookPath); statErr == nil {
1055 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1056 slog.String("error", err.Error()),
1057 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001058 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001059 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1060 }
1061
1062 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001063 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1064 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001065 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001066 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 +01001067 }
1068 } else {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001069 return fmt.Errorf("git checkout %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001070 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001071 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001072 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001073
1074 if ini.HostAddr != "" {
1075 a.url = "http://" + ini.HostAddr
1076 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001077
1078 if !ini.NoGit {
1079 repoRoot, err := repoRoot(ctx, a.workingDir)
1080 if err != nil {
1081 return fmt.Errorf("repoRoot: %w", err)
1082 }
1083 a.repoRoot = repoRoot
1084
Earl Lee2e463fb2025-04-17 11:22:22 -07001085 if err != nil {
1086 return fmt.Errorf("resolveRef: %w", err)
1087 }
Philip Zeyliger49edc922025-05-14 09:45:45 -07001088
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001089 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001090 if err := setupGitHooks(a.repoRoot); err != nil {
1091 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1092 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001093 }
1094
Philip Zeyliger49edc922025-05-14 09:45:45 -07001095 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1096 cmd.Dir = repoRoot
1097 if out, err := cmd.CombinedOutput(); err != nil {
1098 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1099 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001100
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001101 slog.Info("running codebase analysis")
1102 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1103 if err != nil {
1104 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001105 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001106 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001107
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001108 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001109 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001110 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001111 }
1112 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001113
Earl Lee2e463fb2025-04-17 11:22:22 -07001114 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001115 a.gitState.lastHEAD = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001116 a.convo = a.initConvo()
1117 close(a.ready)
1118 return nil
1119}
1120
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001121//go:embed agent_system_prompt.txt
1122var agentSystemPrompt string
1123
Earl Lee2e463fb2025-04-17 11:22:22 -07001124// initConvo initializes the conversation.
1125// It must not be called until all agent fields are initialized,
1126// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001127func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001128 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001129 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -07001130 convo.PromptCaching = true
1131 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001132 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001133 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001134
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001135 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1136 bashPermissionCheck := func(command string) error {
1137 // Check if branch name is set
1138 a.mu.Lock()
Philip Zeyligerf2872992025-05-22 10:35:28 -07001139 branchSet := a.gitState.BranchName() != ""
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001140 a.mu.Unlock()
1141
1142 // If branch is set, all commands are allowed
1143 if branchSet {
1144 return nil
1145 }
1146
1147 // If branch is not set, check if this is a git commit command
1148 willCommit, err := bashkit.WillRunGitCommit(command)
1149 if err != nil {
1150 // If there's an error checking, we should allow the command to proceed
1151 return nil
1152 }
1153
1154 // If it's a git commit and branch is not set, return an error
1155 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001156 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001157 }
1158
1159 return nil
1160 }
1161
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001162 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001163
Earl Lee2e463fb2025-04-17 11:22:22 -07001164 // Register all tools with the conversation
1165 // When adding, removing, or modifying tools here, double-check that the termui tool display
1166 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001167
1168 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001169 _, supportsScreenshots := a.config.Service.(*ant.Service)
1170 var bTools []*llm.Tool
1171 var browserCleanup func()
1172
1173 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1174 // Add cleanup function to context cancel
1175 go func() {
1176 <-a.config.Context.Done()
1177 browserCleanup()
1178 }()
1179 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001180
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001181 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001182 bashTool, claudetool.Keyword, claudetool.Patch,
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001183 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001184 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001185 }
1186
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001187 // One-shot mode is non-interactive, multiple choice requires human response
1188 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001189 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001190 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001191
1192 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -07001193 convo.Listener = a
1194 return convo
1195}
1196
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001197var multipleChoiceTool = &llm.Tool{
1198 Name: "multiplechoice",
1199 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.",
1200 EndsTurn: true,
1201 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001202 "type": "object",
1203 "description": "The question and a list of answers you would expect the user to choose from.",
1204 "properties": {
1205 "question": {
1206 "type": "string",
1207 "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?'"
1208 },
1209 "responseOptions": {
1210 "type": "array",
1211 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1212 "items": {
1213 "type": "object",
1214 "properties": {
1215 "caption": {
1216 "type": "string",
1217 "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'"
1218 },
1219 "responseText": {
1220 "type": "string",
1221 "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'"
1222 }
1223 },
1224 "required": ["caption", "responseText"]
1225 }
1226 }
1227 },
1228 "required": ["question", "responseOptions"]
1229}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001230 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1231 // The Run logic for "multiplechoice" tool is a no-op on the server.
1232 // The UI will present a list of options for the user to select from,
1233 // and that's it as far as "executing" the tool_use goes.
1234 // When the user *does* select one of the presented options, that
1235 // responseText gets sent as a chat message on behalf of the user.
1236 return llm.TextContent("end your turn and wait for the user to respond"), nil
1237 },
Sean McCullough485afc62025-04-28 14:28:39 -07001238}
1239
1240type MultipleChoiceOption struct {
1241 Caption string `json:"caption"`
1242 ResponseText string `json:"responseText"`
1243}
1244
1245type MultipleChoiceParams struct {
1246 Question string `json:"question"`
1247 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1248}
1249
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001250// branchExists reports whether branchName exists, either locally or in well-known remotes.
1251func branchExists(dir, branchName string) bool {
1252 refs := []string{
1253 "refs/heads/",
1254 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001255 }
1256 for _, ref := range refs {
1257 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1258 cmd.Dir = dir
1259 if cmd.Run() == nil { // exit code 0 means branch exists
1260 return true
1261 }
1262 }
1263 return false
1264}
1265
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001266func (a *Agent) titleTool() *llm.Tool {
1267 description := `Sets the conversation title.`
1268 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001269 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001270 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001271 InputSchema: json.RawMessage(`{
1272 "type": "object",
1273 "properties": {
1274 "title": {
1275 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001276 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001277 }
1278 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001279 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001280}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001281 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001282 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001283 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001284 }
1285 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001286 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001287 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001288
1289 // We don't allow changing the title once set to be consistent with the previous behavior
1290 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001291 t := a.Title()
1292 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001293 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001294 }
1295
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001296 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001297 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001298 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001299
1300 a.SetTitle(params.Title)
1301 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001302 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001303 },
1304 }
1305 return titleTool
1306}
1307
1308func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001309 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 +00001310 preCommit := &llm.Tool{
1311 Name: "precommit",
1312 Description: description,
1313 InputSchema: json.RawMessage(`{
1314 "type": "object",
1315 "properties": {
1316 "branch_name": {
1317 "type": "string",
1318 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1319 }
1320 },
1321 "required": ["branch_name"]
1322}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001323 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001324 var params struct {
1325 BranchName string `json:"branch_name"`
1326 }
1327 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001328 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001329 }
1330
1331 b := a.BranchName()
1332 if b != "" {
Josh Bleecher Snyder44d1f1a2025-05-12 19:18:32 -07001333 return nil, fmt.Errorf("branch already set to %s; do not create a new branch", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001334 }
1335
1336 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001337 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001338 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001339 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001340 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001341 }
1342 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001343 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001344 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001345 }
1346
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001347 a.SetBranch(branchName)
Josh Bleecher Snyderf7bebdd2025-05-14 15:22:24 -07001348 response := fmt.Sprintf("switched to branch sketch/%q - DO NOT change branches unless explicitly requested", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001349
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001350 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1351 if err != nil {
1352 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1353 }
1354 if len(styleHint) > 0 {
1355 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001356 }
1357
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001358 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001359 },
1360 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001361 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001362}
1363
1364func (a *Agent) Ready() <-chan struct{} {
1365 return a.ready
1366}
1367
1368func (a *Agent) UserMessage(ctx context.Context, msg string) {
1369 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1370 a.inbox <- msg
1371}
1372
Earl Lee2e463fb2025-04-17 11:22:22 -07001373func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1374 return a.convo.CancelToolUse(toolUseID, cause)
1375}
1376
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001377func (a *Agent) CancelTurn(cause error) {
1378 a.cancelTurnMu.Lock()
1379 defer a.cancelTurnMu.Unlock()
1380 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001381 // Force state transition to cancelled state
1382 ctx := a.config.Context
1383 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001384 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001385 }
1386}
1387
1388func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001389 // Start port monitoring when the agent loop begins
1390 // Only monitor ports when running in a container
1391 if a.IsInContainer() {
1392 a.portMonitor.Start(ctxOuter)
1393 }
1394
Earl Lee2e463fb2025-04-17 11:22:22 -07001395 for {
1396 select {
1397 case <-ctxOuter.Done():
1398 return
1399 default:
1400 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001401 a.cancelTurnMu.Lock()
1402 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001403 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001404 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001405 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001406 a.cancelTurn = cancel
1407 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001408 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1409 if err != nil {
1410 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1411 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001412 cancel(nil)
1413 }
1414 }
1415}
1416
1417func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1418 if m.Timestamp.IsZero() {
1419 m.Timestamp = time.Now()
1420 }
1421
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001422 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1423 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1424 m.Content = m.ToolResult
1425 }
1426
Earl Lee2e463fb2025-04-17 11:22:22 -07001427 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1428 if m.EndOfTurn && m.Type == AgentMessageType {
1429 turnDuration := time.Since(a.startOfTurn)
1430 m.TurnDuration = &turnDuration
1431 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1432 }
1433
Earl Lee2e463fb2025-04-17 11:22:22 -07001434 a.mu.Lock()
1435 defer a.mu.Unlock()
1436 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001437 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001438 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001439
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001440 // Notify all subscribers
1441 for _, ch := range a.subscribers {
1442 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001443 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001444}
1445
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001446func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1447 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001448 if block {
1449 select {
1450 case <-ctx.Done():
1451 return m, ctx.Err()
1452 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001453 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001454 }
1455 }
1456 for {
1457 select {
1458 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001459 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001460 default:
1461 return m, nil
1462 }
1463 }
1464}
1465
Sean McCullough885a16a2025-04-30 02:49:25 +00001466// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001467func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001468 // Reset the start of turn time
1469 a.startOfTurn = time.Now()
1470
Sean McCullough96b60dd2025-04-30 09:49:10 -07001471 // Transition to waiting for user input state
1472 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1473
Sean McCullough885a16a2025-04-30 02:49:25 +00001474 // Process initial user message
1475 initialResp, err := a.processUserMessage(ctx)
1476 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001477 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001478 return err
1479 }
1480
1481 // Handle edge case where both initialResp and err are nil
1482 if initialResp == nil {
1483 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001484 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1485
Sean McCullough9f4b8082025-04-30 17:34:07 +00001486 a.pushToOutbox(ctx, errorMessage(err))
1487 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001488 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001489
Earl Lee2e463fb2025-04-17 11:22:22 -07001490 // We do this as we go, but let's also do it at the end of the turn
1491 defer func() {
1492 if _, err := a.handleGitCommits(ctx); 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 }
1496 }()
1497
Sean McCullougha1e0e492025-05-01 10:51:08 -07001498 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001499 resp := initialResp
1500 for {
1501 // Check if we are over budget
1502 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001503 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001504 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001505 }
1506
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001507 // Check if we should compact the conversation
1508 if a.ShouldCompact() {
1509 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1510 if err := a.CompactConversation(ctx); err != nil {
1511 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1512 return err
1513 }
1514 // After compaction, end this turn and start fresh
1515 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1516 return nil
1517 }
1518
Sean McCullough885a16a2025-04-30 02:49:25 +00001519 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001520 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001521 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001522 break
1523 }
1524
Sean McCullough96b60dd2025-04-30 09:49:10 -07001525 // Transition to tool use requested state
1526 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1527
Sean McCullough885a16a2025-04-30 02:49:25 +00001528 // Handle tool execution
1529 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1530 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001531 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001532 }
1533
Sean McCullougha1e0e492025-05-01 10:51:08 -07001534 if toolResp == nil {
1535 return fmt.Errorf("cannot continue conversation with a nil tool response")
1536 }
1537
Sean McCullough885a16a2025-04-30 02:49:25 +00001538 // Set the response for the next iteration
1539 resp = toolResp
1540 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001541
1542 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001543}
1544
1545// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001546func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001547 // Wait for at least one message from the user
1548 msgs, err := a.GatherMessages(ctx, true)
1549 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001550 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001551 return nil, err
1552 }
1553
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001554 userMessage := llm.Message{
1555 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001556 Content: msgs,
1557 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001558
Sean McCullough96b60dd2025-04-30 09:49:10 -07001559 // Transition to sending to LLM state
1560 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1561
Sean McCullough885a16a2025-04-30 02:49:25 +00001562 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001563 resp, err := a.convo.SendMessage(userMessage)
1564 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001565 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001566 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001567 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001568 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001569
Sean McCullough96b60dd2025-04-30 09:49:10 -07001570 // Transition to processing LLM response state
1571 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1572
Sean McCullough885a16a2025-04-30 02:49:25 +00001573 return resp, nil
1574}
1575
1576// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001577func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1578 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001579 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001580 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001581
Sean McCullough96b60dd2025-04-30 09:49:10 -07001582 // Transition to checking for cancellation state
1583 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1584
Sean McCullough885a16a2025-04-30 02:49:25 +00001585 // Check if the operation was cancelled by the user
1586 select {
1587 case <-ctx.Done():
1588 // Don't actually run any of the tools, but rather build a response
1589 // for each tool_use message letting the LLM know that user canceled it.
1590 var err error
1591 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001592 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001593 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001594 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001595 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001596 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001597 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001598 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001599 // Transition to running tool state
1600 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1601
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001602 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001603 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001604 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001605
1606 // Execute the tools
1607 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001608 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001609 if ctx.Err() != nil { // e.g. the user canceled the operation
1610 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001611 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001612 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001613 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001614 a.pushToOutbox(ctx, errorMessage(err))
1615 }
1616 }
1617
1618 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001619 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001620 autoqualityMessages := a.processGitChanges(ctx)
1621
1622 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001623 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001624 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001625 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001626 return false, nil
1627 }
1628
1629 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001630 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1631 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001632}
1633
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001634// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001635func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001636 // Check for git commits
1637 _, err := a.handleGitCommits(ctx)
1638 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001639 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001640 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001641 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001642 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001643}
1644
1645// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1646// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001647func (a *Agent) processGitChanges(ctx context.Context) []string {
1648 // Check for git commits after tool execution
1649 newCommits, err := a.handleGitCommits(ctx)
1650 if err != nil {
1651 // Just log the error, don't stop execution
1652 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1653 return nil
1654 }
1655
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001656 // Run mechanical checks if there was exactly one new commit.
1657 if len(newCommits) != 1 {
1658 return nil
1659 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001660 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001661 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1662 msg := a.codereview.RunMechanicalChecks(ctx)
1663 if msg != "" {
1664 a.pushToOutbox(ctx, AgentMessage{
1665 Type: AutoMessageType,
1666 Content: msg,
1667 Timestamp: time.Now(),
1668 })
1669 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001670 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001671
1672 return autoqualityMessages
1673}
1674
1675// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001676func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001677 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001678 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001679 msgs, err := a.GatherMessages(ctx, false)
1680 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001681 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001682 return false, nil
1683 }
1684
1685 // Inject any auto-generated messages from quality checks
1686 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001687 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001688 }
1689
1690 // Handle cancellation by appending a message about it
1691 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001692 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001693 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001694 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001695 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1696 } else if err := a.convo.OverBudget(); err != nil {
1697 // Handle budget issues by appending a message about it
1698 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 -07001699 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001700 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1701 }
1702
1703 // Combine tool results with user messages
1704 results = append(results, msgs...)
1705
1706 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001707 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001708 resp, err := a.convo.SendMessage(llm.Message{
1709 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001710 Content: results,
1711 })
1712 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001713 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001714 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1715 return true, nil // Return true to continue the conversation, but with no response
1716 }
1717
Sean McCullough96b60dd2025-04-30 09:49:10 -07001718 // Transition back to processing LLM response
1719 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1720
Sean McCullough885a16a2025-04-30 02:49:25 +00001721 if cancelled {
1722 return false, nil
1723 }
1724
1725 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001726}
1727
1728func (a *Agent) overBudget(ctx context.Context) error {
1729 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001730 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001731 m := budgetMessage(err)
1732 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001733 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001734 a.convo.ResetBudget(a.originalBudget)
1735 return err
1736 }
1737 return nil
1738}
1739
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001740func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001741 // Collect all text content
1742 var allText strings.Builder
1743 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001744 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001745 if allText.Len() > 0 {
1746 allText.WriteString("\n\n")
1747 }
1748 allText.WriteString(content.Text)
1749 }
1750 }
1751 return allText.String()
1752}
1753
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001754func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001755 a.mu.Lock()
1756 defer a.mu.Unlock()
1757 return a.convo.CumulativeUsage()
1758}
1759
Earl Lee2e463fb2025-04-17 11:22:22 -07001760// Diff returns a unified diff of changes made since the agent was instantiated.
1761func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001762 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001763 return "", fmt.Errorf("no initial commit reference available")
1764 }
1765
1766 // Find the repository root
1767 ctx := context.Background()
1768
1769 // If a specific commit hash is provided, show just that commit's changes
1770 if commit != nil && *commit != "" {
1771 // Validate that the commit looks like a valid git SHA
1772 if !isValidGitSHA(*commit) {
1773 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1774 }
1775
1776 // Get the diff for just this commit
1777 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1778 cmd.Dir = a.repoRoot
1779 output, err := cmd.CombinedOutput()
1780 if err != nil {
1781 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1782 }
1783 return string(output), nil
1784 }
1785
1786 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001787 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001788 cmd.Dir = a.repoRoot
1789 output, err := cmd.CombinedOutput()
1790 if err != nil {
1791 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1792 }
1793
1794 return string(output), nil
1795}
1796
Philip Zeyliger49edc922025-05-14 09:45:45 -07001797// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1798// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1799func (a *Agent) SketchGitBaseRef() string {
1800 if a.IsInContainer() {
1801 return "sketch-base"
1802 } else {
1803 return "sketch-base-" + a.SessionID()
1804 }
1805}
1806
1807// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1808func (a *Agent) SketchGitBase() string {
1809 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1810 cmd.Dir = a.repoRoot
1811 output, err := cmd.CombinedOutput()
1812 if err != nil {
1813 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1814 return "HEAD"
1815 }
1816 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001817}
1818
Pokey Rule7a113622025-05-12 10:58:45 +01001819// removeGitHooks removes the Git hooks directory from the repository
1820func removeGitHooks(_ context.Context, repoPath string) error {
1821 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1822
1823 // Check if hooks directory exists
1824 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1825 // Directory doesn't exist, nothing to do
1826 return nil
1827 }
1828
1829 // Remove the hooks directory
1830 err := os.RemoveAll(hooksDir)
1831 if err != nil {
1832 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1833 }
1834
1835 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001836 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001837 if err != nil {
1838 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1839 }
1840
1841 return nil
1842}
1843
Philip Zeyligerf2872992025-05-22 10:35:28 -07001844func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1845 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef())
1846 for _, msg := range msgs {
1847 a.pushToOutbox(ctx, msg)
1848 }
1849 return commits, error
1850}
1851
Earl Lee2e463fb2025-04-17 11:22:22 -07001852// handleGitCommits() highlights new commits to the user. When running
1853// under docker, new HEADs are pushed to a branch according to the title.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001854func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string) ([]AgentMessage, []*GitCommit, error) {
1855 ags.mu.Lock()
1856 defer ags.mu.Unlock()
1857
1858 msgs := []AgentMessage{}
1859 if repoRoot == "" {
1860 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001861 }
1862
Philip Zeyligerf2872992025-05-22 10:35:28 -07001863 head, err := resolveRef(ctx, repoRoot, "HEAD")
Earl Lee2e463fb2025-04-17 11:22:22 -07001864 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001865 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001866 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001867 if head == ags.lastHEAD {
1868 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001869 }
1870 defer func() {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001871 ags.lastHEAD = head
Earl Lee2e463fb2025-04-17 11:22:22 -07001872 }()
1873
1874 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1875 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1876 // to the last 100 commits.
1877 var commits []*GitCommit
1878
1879 // Get commits since the initial commit
1880 // Format: <hash>\0<subject>\0<body>\0
1881 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1882 // Limit to 100 commits to avoid overwhelming the user
Philip Zeyligerf2872992025-05-22 10:35:28 -07001883 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, head)
1884 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001885 output, err := cmd.Output()
1886 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001887 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001888 }
1889
1890 // Parse git log output and filter out already seen commits
1891 parsedCommits := parseGitLog(string(output))
1892
1893 var headCommit *GitCommit
1894
1895 // Filter out commits we've already seen
1896 for _, commit := range parsedCommits {
1897 if commit.Hash == head {
1898 headCommit = &commit
1899 }
1900
1901 // Skip if we've seen this commit before. If our head has changed, always include that.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001902 if ags.seenCommits[commit.Hash] && commit.Hash != head {
Earl Lee2e463fb2025-04-17 11:22:22 -07001903 continue
1904 }
1905
1906 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001907 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001908
1909 // Add to our list of new commits
1910 commits = append(commits, &commit)
1911 }
1912
Philip Zeyligerf2872992025-05-22 10:35:28 -07001913 if ags.gitRemoteAddr != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001914 if headCommit == nil {
1915 // I think this can only happen if we have a bug or if there's a race.
1916 headCommit = &GitCommit{}
1917 headCommit.Hash = head
1918 headCommit.Subject = "unknown"
1919 commits = append(commits, headCommit)
1920 }
1921
Philip Zeyligerf2872992025-05-22 10:35:28 -07001922 originalBranch := cmp.Or(ags.branchName, "sketch/"+sessionID)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001923 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001924
1925 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1926 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1927 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001928
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001929 // Parse the original branch name to extract base name and starting number
1930 baseBranch, startNum := parseBranchNameAndNumber(originalBranch)
1931
Philip Zeyliger113e2052025-05-09 21:59:40 +00001932 // Try up to 10 times with different branch names if the branch is checked out on the remote
1933 var out []byte
1934 var err error
1935 for retries := range 10 {
1936 if retries > 0 {
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001937 // Increment from the starting number (foo1->foo2, foo2->foo3, etc.)
1938 branch = fmt.Sprintf("%s%d", baseBranch, startNum+retries)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001939 }
1940
Philip Zeyligerf2872992025-05-22 10:35:28 -07001941 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1942 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001943 out, err = cmd.CombinedOutput()
1944
1945 if err == nil {
1946 // Success! Break out of the retry loop
1947 break
1948 }
1949
1950 // Check if this is the "refusing to update checked out branch" error
1951 if !strings.Contains(string(out), "refusing to update checked out branch") {
1952 // This is a different error, so don't retry
1953 break
1954 }
1955
1956 // If we're on the last retry, we'll report the error
1957 if retries == 9 {
1958 break
1959 }
1960 }
1961
1962 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001963 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001964 } else {
1965 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001966 // Update the agent's branch name if we ended up using a different one
1967 if branch != originalBranch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001968 ags.branchName = branch
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001969 // Notify user why the branch name was changed
1970 msgs = append(msgs, AgentMessage{
1971 Type: AutoMessageType,
1972 Timestamp: time.Now(),
1973 Content: fmt.Sprintf("Branch renamed from %s to %s because the original branch is currently checked out on the remote.", originalBranch, branch),
1974 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00001975 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001976 }
1977 }
1978
1979 // If we found new commits, create a message
1980 if len(commits) > 0 {
1981 msg := AgentMessage{
1982 Type: CommitMessageType,
1983 Timestamp: time.Now(),
1984 Commits: commits,
1985 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001986 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001987 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001988 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001989}
1990
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001991func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001992 return strings.Map(func(r rune) rune {
1993 // lowercase
1994 if r >= 'A' && r <= 'Z' {
1995 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001996 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001997 // replace spaces with dashes
1998 if r == ' ' {
1999 return '-'
2000 }
2001 // allow alphanumerics and dashes
2002 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2003 return r
2004 }
2005 return -1
2006 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002007}
2008
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002009// parseBranchNameAndNumber extracts the base branch name and starting number.
2010// For "sketch/foo1" returns ("sketch/foo", 1)
2011// For "sketch/foo" returns ("sketch/foo", 0)
2012func parseBranchNameAndNumber(branchName string) (baseBranch string, startNum int) {
2013 re := regexp.MustCompile(`^(.+?)(\d+)$`)
2014 matches := re.FindStringSubmatch(branchName)
2015
2016 if len(matches) != 3 {
2017 // No trailing digits found
2018 return branchName, 0
2019 }
2020
2021 num, err := strconv.Atoi(matches[2])
2022 if err != nil {
2023 // If parsing fails, treat as no number
2024 return branchName, 0
2025 }
2026
2027 return matches[1], num
2028}
2029
Earl Lee2e463fb2025-04-17 11:22:22 -07002030// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2031// and returns an array of GitCommit structs.
2032func parseGitLog(output string) []GitCommit {
2033 var commits []GitCommit
2034
2035 // No output means no commits
2036 if len(output) == 0 {
2037 return commits
2038 }
2039
2040 // Split by NULL byte
2041 parts := strings.Split(output, "\x00")
2042
2043 // Process in triplets (hash, subject, body)
2044 for i := 0; i < len(parts); i++ {
2045 // Skip empty parts
2046 if parts[i] == "" {
2047 continue
2048 }
2049
2050 // This should be a hash
2051 hash := strings.TrimSpace(parts[i])
2052
2053 // Make sure we have at least a subject part available
2054 if i+1 >= len(parts) {
2055 break // No more parts available
2056 }
2057
2058 // Get the subject
2059 subject := strings.TrimSpace(parts[i+1])
2060
2061 // Get the body if available
2062 body := ""
2063 if i+2 < len(parts) {
2064 body = strings.TrimSpace(parts[i+2])
2065 }
2066
2067 // Skip to the next triplet
2068 i += 2
2069
2070 commits = append(commits, GitCommit{
2071 Hash: hash,
2072 Subject: subject,
2073 Body: body,
2074 })
2075 }
2076
2077 return commits
2078}
2079
2080func repoRoot(ctx context.Context, dir string) (string, error) {
2081 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2082 stderr := new(strings.Builder)
2083 cmd.Stderr = stderr
2084 cmd.Dir = dir
2085 out, err := cmd.Output()
2086 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002087 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002088 }
2089 return strings.TrimSpace(string(out)), nil
2090}
2091
2092func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2093 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2094 stderr := new(strings.Builder)
2095 cmd.Stderr = stderr
2096 cmd.Dir = dir
2097 out, err := cmd.Output()
2098 if err != nil {
2099 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2100 }
2101 // TODO: validate that out is valid hex
2102 return strings.TrimSpace(string(out)), nil
2103}
2104
2105// isValidGitSHA validates if a string looks like a valid git SHA hash.
2106// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2107func isValidGitSHA(sha string) bool {
2108 // Git SHA must be a hexadecimal string with at least 4 characters
2109 if len(sha) < 4 || len(sha) > 40 {
2110 return false
2111 }
2112
2113 // Check if the string only contains hexadecimal characters
2114 for _, char := range sha {
2115 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2116 return false
2117 }
2118 }
2119
2120 return true
2121}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002122
2123// getGitOrigin returns the URL of the git remote 'origin' if it exists
2124func getGitOrigin(ctx context.Context, dir string) string {
2125 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2126 cmd.Dir = dir
2127 stderr := new(strings.Builder)
2128 cmd.Stderr = stderr
2129 out, err := cmd.Output()
2130 if err != nil {
2131 return ""
2132 }
2133 return strings.TrimSpace(string(out))
2134}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002135
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002136// systemPromptData contains the data used to render the system prompt template
2137type systemPromptData struct {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002138 ClientGOOS string
2139 ClientGOARCH string
2140 WorkingDir string
2141 RepoRoot string
2142 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002143 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002144}
2145
2146// renderSystemPrompt renders the system prompt template.
2147func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002148 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002149 ClientGOOS: a.config.ClientGOOS,
2150 ClientGOARCH: a.config.ClientGOARCH,
2151 WorkingDir: a.workingDir,
2152 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002153 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002154 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002155 }
2156
2157 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2158 if err != nil {
2159 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2160 }
2161 buf := new(strings.Builder)
2162 err = tmpl.Execute(buf, data)
2163 if err != nil {
2164 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2165 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002166 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002167 return buf.String()
2168}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002169
2170// StateTransitionIterator provides an iterator over state transitions.
2171type StateTransitionIterator interface {
2172 // Next blocks until a new state transition is available or context is done.
2173 // Returns nil if the context is cancelled.
2174 Next() *StateTransition
2175 // Close removes the listener and cleans up resources.
2176 Close()
2177}
2178
2179// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2180type StateTransitionIteratorImpl struct {
2181 agent *Agent
2182 ctx context.Context
2183 ch chan StateTransition
2184 unsubscribe func()
2185}
2186
2187// Next blocks until a new state transition is available or the context is cancelled.
2188func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2189 select {
2190 case <-s.ctx.Done():
2191 return nil
2192 case transition, ok := <-s.ch:
2193 if !ok {
2194 return nil
2195 }
2196 transitionCopy := transition
2197 return &transitionCopy
2198 }
2199}
2200
2201// Close removes the listener and cleans up resources.
2202func (s *StateTransitionIteratorImpl) Close() {
2203 if s.unsubscribe != nil {
2204 s.unsubscribe()
2205 s.unsubscribe = nil
2206 }
2207}
2208
2209// NewStateTransitionIterator returns an iterator that receives state transitions.
2210func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2211 a.mu.Lock()
2212 defer a.mu.Unlock()
2213
2214 // Create channel to receive state transitions
2215 ch := make(chan StateTransition, 10)
2216
2217 // Add a listener to the state machine
2218 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2219
2220 return &StateTransitionIteratorImpl{
2221 agent: a,
2222 ctx: ctx,
2223 ch: ch,
2224 unsubscribe: unsubscribe,
2225 }
2226}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002227
2228// setupGitHooks creates or updates git hooks in the specified working directory.
2229func setupGitHooks(workingDir string) error {
2230 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2231
2232 _, err := os.Stat(hooksDir)
2233 if os.IsNotExist(err) {
2234 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2235 }
2236 if err != nil {
2237 return fmt.Errorf("error checking git hooks directory: %w", err)
2238 }
2239
2240 // Define the post-commit hook content
2241 postCommitHook := `#!/bin/bash
2242echo "<post_commit_hook>"
2243echo "Please review this commit message and fix it if it is incorrect."
2244echo "This hook only echos the commit message; it does not modify it."
2245echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2246echo "<last_commit_message>"
2247git log -1 --pretty=%B
2248echo "</last_commit_message>"
2249echo "</post_commit_hook>"
2250`
2251
2252 // Define the prepare-commit-msg hook content
2253 prepareCommitMsgHook := `#!/bin/bash
2254# Add Co-Authored-By and Change-ID trailers to commit messages
2255# Check if these trailers already exist before adding them
2256
2257commit_file="$1"
2258COMMIT_SOURCE="$2"
2259
2260# Skip for merges, squashes, or when using a commit template
2261if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2262 [ "$COMMIT_SOURCE" = "squash" ]; then
2263 exit 0
2264fi
2265
2266commit_msg=$(cat "$commit_file")
2267
2268needs_co_author=true
2269needs_change_id=true
2270
2271# Check if commit message already has Co-Authored-By trailer
2272if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2273 needs_co_author=false
2274fi
2275
2276# Check if commit message already has Change-ID trailer
2277if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2278 needs_change_id=false
2279fi
2280
2281# Only modify if at least one trailer needs to be added
2282if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002283 # Ensure there's a proper blank line before trailers
2284 if [ -s "$commit_file" ]; then
2285 # Check if file ends with newline by reading last character
2286 last_char=$(tail -c 1 "$commit_file")
2287
2288 if [ "$last_char" != "" ]; then
2289 # File doesn't end with newline - add two newlines (complete line + blank line)
2290 echo "" >> "$commit_file"
2291 echo "" >> "$commit_file"
2292 else
2293 # File ends with newline - check if we already have a blank line
2294 last_line=$(tail -1 "$commit_file")
2295 if [ -n "$last_line" ]; then
2296 # Last line has content - add one newline for blank line
2297 echo "" >> "$commit_file"
2298 fi
2299 # If last line is empty, we already have a blank line - don't add anything
2300 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002301 fi
2302
2303 # Add trailers if needed
2304 if [ "$needs_co_author" = true ]; then
2305 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2306 fi
2307
2308 if [ "$needs_change_id" = true ]; then
2309 change_id=$(openssl rand -hex 8)
2310 echo "Change-ID: s${change_id}k" >> "$commit_file"
2311 fi
2312fi
2313`
2314
2315 // Update or create the post-commit hook
2316 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2317 if err != nil {
2318 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2319 }
2320
2321 // Update or create the prepare-commit-msg hook
2322 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2323 if err != nil {
2324 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2325 }
2326
2327 return nil
2328}
2329
2330// updateOrCreateHook creates a new hook file or updates an existing one
2331// by appending the new content if it doesn't already contain it.
2332func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2333 // Check if the hook already exists
2334 buf, err := os.ReadFile(hookPath)
2335 if os.IsNotExist(err) {
2336 // Hook doesn't exist, create it
2337 err = os.WriteFile(hookPath, []byte(content), 0o755)
2338 if err != nil {
2339 return fmt.Errorf("failed to create hook: %w", err)
2340 }
2341 return nil
2342 }
2343 if err != nil {
2344 return fmt.Errorf("error reading existing hook: %w", err)
2345 }
2346
2347 // Hook exists, check if our content is already in it by looking for a distinctive line
2348 code := string(buf)
2349 if strings.Contains(code, distinctiveLine) {
2350 // Already contains our content, nothing to do
2351 return nil
2352 }
2353
2354 // Append our content to the existing hook
2355 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2356 if err != nil {
2357 return fmt.Errorf("failed to open hook for appending: %w", err)
2358 }
2359 defer f.Close()
2360
2361 // Ensure there's a newline at the end of the existing content if needed
2362 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2363 _, err = f.WriteString("\n")
2364 if err != nil {
2365 return fmt.Errorf("failed to add newline to hook: %w", err)
2366 }
2367 }
2368
2369 // Add a separator before our content
2370 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2371 if err != nil {
2372 return fmt.Errorf("failed to append to hook: %w", err)
2373 }
2374
2375 return nil
2376}
Sean McCullough138ec242025-06-02 22:42:06 +00002377
2378// GetPortMonitor returns the port monitor instance for accessing port events
2379func (a *Agent) GetPortMonitor() *PortMonitor {
2380 return a.portMonitor
2381}