blob: c369603fd3a132c8e515e25436bc1ea02f98c94b [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 Zeyliger222bf412025-06-04 16:42:58 +00001008 // If a remote git addr was specified, we configure the origin remote
Philip Zeyligerf2872992025-05-22 10:35:28 -07001009 if a.gitState.gitRemoteAddr != "" {
1010 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
Philip Zeyliger222bf412025-06-04 16:42:58 +00001011
1012 // Remove existing origin remote if it exists
1013 cmd := exec.CommandContext(ctx, "git", "remote", "remove", "origin")
Philip Zeyligerf2872992025-05-22 10:35:28 -07001014 cmd.Dir = a.workingDir
1015 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001016 // Ignore error if origin doesn't exist
1017 slog.DebugContext(ctx, "git remote remove origin (ignoring if not exists)", slog.String("output", string(out)))
Philip Zeyligerf2872992025-05-22 10:35:28 -07001018 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001019
1020 // Add the new remote as origin
1021 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", a.gitState.gitRemoteAddr)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001022 cmd.Dir = a.workingDir
1023 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001024 return fmt.Errorf("git remote add origin: %s: %v", out, err)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001025 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001026
Philip Zeyligerf2872992025-05-22 10:35:28 -07001027 }
1028
1029 // If a commit was specified, we fetch and reset to it.
1030 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001031 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1032
Earl Lee2e463fb2025-04-17 11:22:22 -07001033 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001034 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001035 if out, err := cmd.CombinedOutput(); err != nil {
1036 return fmt.Errorf("git stash: %s: %v", out, err)
1037 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001038 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
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 fetch: %s: %w", out, err)
1042 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001043 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1044 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001045 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1046 // Remove git hooks if they exist and retry
1047 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001048 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001049 if _, statErr := os.Stat(hookPath); statErr == nil {
1050 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1051 slog.String("error", err.Error()),
1052 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001053 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001054 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1055 }
1056
1057 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001058 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1059 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001060 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001061 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 +01001062 }
1063 } else {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001064 return fmt.Errorf("git checkout %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001065 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001066 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001067 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001068
1069 if ini.HostAddr != "" {
1070 a.url = "http://" + ini.HostAddr
1071 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001072
1073 if !ini.NoGit {
1074 repoRoot, err := repoRoot(ctx, a.workingDir)
1075 if err != nil {
1076 return fmt.Errorf("repoRoot: %w", err)
1077 }
1078 a.repoRoot = repoRoot
1079
Earl Lee2e463fb2025-04-17 11:22:22 -07001080 if err != nil {
1081 return fmt.Errorf("resolveRef: %w", err)
1082 }
Philip Zeyliger49edc922025-05-14 09:45:45 -07001083
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001084 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001085 if err := setupGitHooks(a.repoRoot); err != nil {
1086 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1087 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001088 }
1089
Philip Zeyliger49edc922025-05-14 09:45:45 -07001090 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1091 cmd.Dir = repoRoot
1092 if out, err := cmd.CombinedOutput(); err != nil {
1093 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1094 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001095
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001096 slog.Info("running codebase analysis")
1097 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1098 if err != nil {
1099 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001100 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001101 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001102
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001103 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001104 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001105 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001106 }
1107 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001108
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001109 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -07001110 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001111 a.gitState.lastHEAD = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001112 a.convo = a.initConvo()
1113 close(a.ready)
1114 return nil
1115}
1116
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001117//go:embed agent_system_prompt.txt
1118var agentSystemPrompt string
1119
Earl Lee2e463fb2025-04-17 11:22:22 -07001120// initConvo initializes the conversation.
1121// It must not be called until all agent fields are initialized,
1122// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001123func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001124 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001125 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -07001126 convo.PromptCaching = true
1127 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001128 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001129 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001130
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001131 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1132 bashPermissionCheck := func(command string) error {
1133 // Check if branch name is set
1134 a.mu.Lock()
Philip Zeyligerf2872992025-05-22 10:35:28 -07001135 branchSet := a.gitState.BranchName() != ""
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001136 a.mu.Unlock()
1137
1138 // If branch is set, all commands are allowed
1139 if branchSet {
1140 return nil
1141 }
1142
1143 // If branch is not set, check if this is a git commit command
1144 willCommit, err := bashkit.WillRunGitCommit(command)
1145 if err != nil {
1146 // If there's an error checking, we should allow the command to proceed
1147 return nil
1148 }
1149
1150 // If it's a git commit and branch is not set, return an error
1151 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001152 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001153 }
1154
1155 return nil
1156 }
1157
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001158 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001159
Earl Lee2e463fb2025-04-17 11:22:22 -07001160 // Register all tools with the conversation
1161 // When adding, removing, or modifying tools here, double-check that the termui tool display
1162 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001163
1164 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001165 _, supportsScreenshots := a.config.Service.(*ant.Service)
1166 var bTools []*llm.Tool
1167 var browserCleanup func()
1168
1169 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1170 // Add cleanup function to context cancel
1171 go func() {
1172 <-a.config.Context.Done()
1173 browserCleanup()
1174 }()
1175 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001176
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001177 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001178 bashTool, claudetool.Keyword, claudetool.Patch,
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001179 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001180 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001181 }
1182
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001183 // One-shot mode is non-interactive, multiple choice requires human response
1184 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001185 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001186 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001187
1188 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -07001189 convo.Listener = a
1190 return convo
1191}
1192
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001193var multipleChoiceTool = &llm.Tool{
1194 Name: "multiplechoice",
1195 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.",
1196 EndsTurn: true,
1197 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001198 "type": "object",
1199 "description": "The question and a list of answers you would expect the user to choose from.",
1200 "properties": {
1201 "question": {
1202 "type": "string",
1203 "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?'"
1204 },
1205 "responseOptions": {
1206 "type": "array",
1207 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1208 "items": {
1209 "type": "object",
1210 "properties": {
1211 "caption": {
1212 "type": "string",
1213 "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'"
1214 },
1215 "responseText": {
1216 "type": "string",
1217 "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'"
1218 }
1219 },
1220 "required": ["caption", "responseText"]
1221 }
1222 }
1223 },
1224 "required": ["question", "responseOptions"]
1225}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001226 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1227 // The Run logic for "multiplechoice" tool is a no-op on the server.
1228 // The UI will present a list of options for the user to select from,
1229 // and that's it as far as "executing" the tool_use goes.
1230 // When the user *does* select one of the presented options, that
1231 // responseText gets sent as a chat message on behalf of the user.
1232 return llm.TextContent("end your turn and wait for the user to respond"), nil
1233 },
Sean McCullough485afc62025-04-28 14:28:39 -07001234}
1235
1236type MultipleChoiceOption struct {
1237 Caption string `json:"caption"`
1238 ResponseText string `json:"responseText"`
1239}
1240
1241type MultipleChoiceParams struct {
1242 Question string `json:"question"`
1243 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1244}
1245
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001246// branchExists reports whether branchName exists, either locally or in well-known remotes.
1247func branchExists(dir, branchName string) bool {
1248 refs := []string{
1249 "refs/heads/",
1250 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001251 }
1252 for _, ref := range refs {
1253 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1254 cmd.Dir = dir
1255 if cmd.Run() == nil { // exit code 0 means branch exists
1256 return true
1257 }
1258 }
1259 return false
1260}
1261
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001262func (a *Agent) titleTool() *llm.Tool {
1263 description := `Sets the conversation title.`
1264 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001265 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001266 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001267 InputSchema: json.RawMessage(`{
1268 "type": "object",
1269 "properties": {
1270 "title": {
1271 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001272 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001273 }
1274 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001275 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001276}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001277 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001278 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001279 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001280 }
1281 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001282 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001283 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001284
1285 // We don't allow changing the title once set to be consistent with the previous behavior
1286 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001287 t := a.Title()
1288 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001289 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001290 }
1291
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001292 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001293 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001294 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001295
1296 a.SetTitle(params.Title)
1297 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001298 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001299 },
1300 }
1301 return titleTool
1302}
1303
1304func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001305 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 +00001306 preCommit := &llm.Tool{
1307 Name: "precommit",
1308 Description: description,
1309 InputSchema: json.RawMessage(`{
1310 "type": "object",
1311 "properties": {
1312 "branch_name": {
1313 "type": "string",
1314 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1315 }
1316 },
1317 "required": ["branch_name"]
1318}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001319 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001320 var params struct {
1321 BranchName string `json:"branch_name"`
1322 }
1323 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001324 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001325 }
1326
1327 b := a.BranchName()
1328 if b != "" {
Josh Bleecher Snyder44d1f1a2025-05-12 19:18:32 -07001329 return nil, fmt.Errorf("branch already set to %s; do not create a new branch", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001330 }
1331
1332 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001333 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001334 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001335 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001336 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001337 }
1338 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001339 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001340 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001341 }
1342
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001343 a.SetBranch(branchName)
Josh Bleecher Snyderf7bebdd2025-05-14 15:22:24 -07001344 response := fmt.Sprintf("switched to branch sketch/%q - DO NOT change branches unless explicitly requested", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001345
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001346 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1347 if err != nil {
1348 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1349 }
1350 if len(styleHint) > 0 {
1351 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001352 }
1353
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001354 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001355 },
1356 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001357 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001358}
1359
1360func (a *Agent) Ready() <-chan struct{} {
1361 return a.ready
1362}
1363
1364func (a *Agent) UserMessage(ctx context.Context, msg string) {
1365 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1366 a.inbox <- msg
1367}
1368
Earl Lee2e463fb2025-04-17 11:22:22 -07001369func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1370 return a.convo.CancelToolUse(toolUseID, cause)
1371}
1372
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001373func (a *Agent) CancelTurn(cause error) {
1374 a.cancelTurnMu.Lock()
1375 defer a.cancelTurnMu.Unlock()
1376 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001377 // Force state transition to cancelled state
1378 ctx := a.config.Context
1379 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001380 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001381 }
1382}
1383
1384func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001385 // Start port monitoring when the agent loop begins
1386 // Only monitor ports when running in a container
1387 if a.IsInContainer() {
1388 a.portMonitor.Start(ctxOuter)
1389 }
1390
Earl Lee2e463fb2025-04-17 11:22:22 -07001391 for {
1392 select {
1393 case <-ctxOuter.Done():
1394 return
1395 default:
1396 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001397 a.cancelTurnMu.Lock()
1398 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001399 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001400 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001401 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001402 a.cancelTurn = cancel
1403 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001404 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1405 if err != nil {
1406 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1407 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001408 cancel(nil)
1409 }
1410 }
1411}
1412
1413func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1414 if m.Timestamp.IsZero() {
1415 m.Timestamp = time.Now()
1416 }
1417
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001418 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1419 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1420 m.Content = m.ToolResult
1421 }
1422
Earl Lee2e463fb2025-04-17 11:22:22 -07001423 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1424 if m.EndOfTurn && m.Type == AgentMessageType {
1425 turnDuration := time.Since(a.startOfTurn)
1426 m.TurnDuration = &turnDuration
1427 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1428 }
1429
Earl Lee2e463fb2025-04-17 11:22:22 -07001430 a.mu.Lock()
1431 defer a.mu.Unlock()
1432 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001433 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001434 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001435
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001436 // Notify all subscribers
1437 for _, ch := range a.subscribers {
1438 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001439 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001440}
1441
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001442func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1443 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001444 if block {
1445 select {
1446 case <-ctx.Done():
1447 return m, ctx.Err()
1448 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001449 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001450 }
1451 }
1452 for {
1453 select {
1454 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001455 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001456 default:
1457 return m, nil
1458 }
1459 }
1460}
1461
Sean McCullough885a16a2025-04-30 02:49:25 +00001462// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001463func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001464 // Reset the start of turn time
1465 a.startOfTurn = time.Now()
1466
Sean McCullough96b60dd2025-04-30 09:49:10 -07001467 // Transition to waiting for user input state
1468 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1469
Sean McCullough885a16a2025-04-30 02:49:25 +00001470 // Process initial user message
1471 initialResp, err := a.processUserMessage(ctx)
1472 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001473 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001474 return err
1475 }
1476
1477 // Handle edge case where both initialResp and err are nil
1478 if initialResp == nil {
1479 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001480 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1481
Sean McCullough9f4b8082025-04-30 17:34:07 +00001482 a.pushToOutbox(ctx, errorMessage(err))
1483 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001484 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001485
Earl Lee2e463fb2025-04-17 11:22:22 -07001486 // We do this as we go, but let's also do it at the end of the turn
1487 defer func() {
1488 if _, err := a.handleGitCommits(ctx); err != nil {
1489 // Just log the error, don't stop execution
1490 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1491 }
1492 }()
1493
Sean McCullougha1e0e492025-05-01 10:51:08 -07001494 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001495 resp := initialResp
1496 for {
1497 // Check if we are over budget
1498 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001499 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001500 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001501 }
1502
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001503 // Check if we should compact the conversation
1504 if a.ShouldCompact() {
1505 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1506 if err := a.CompactConversation(ctx); err != nil {
1507 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1508 return err
1509 }
1510 // After compaction, end this turn and start fresh
1511 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1512 return nil
1513 }
1514
Sean McCullough885a16a2025-04-30 02:49:25 +00001515 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001516 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001517 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001518 break
1519 }
1520
Sean McCullough96b60dd2025-04-30 09:49:10 -07001521 // Transition to tool use requested state
1522 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1523
Sean McCullough885a16a2025-04-30 02:49:25 +00001524 // Handle tool execution
1525 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1526 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001527 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001528 }
1529
Sean McCullougha1e0e492025-05-01 10:51:08 -07001530 if toolResp == nil {
1531 return fmt.Errorf("cannot continue conversation with a nil tool response")
1532 }
1533
Sean McCullough885a16a2025-04-30 02:49:25 +00001534 // Set the response for the next iteration
1535 resp = toolResp
1536 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001537
1538 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001539}
1540
1541// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001542func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001543 // Wait for at least one message from the user
1544 msgs, err := a.GatherMessages(ctx, true)
1545 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001546 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001547 return nil, err
1548 }
1549
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001550 userMessage := llm.Message{
1551 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001552 Content: msgs,
1553 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001554
Sean McCullough96b60dd2025-04-30 09:49:10 -07001555 // Transition to sending to LLM state
1556 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1557
Sean McCullough885a16a2025-04-30 02:49:25 +00001558 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001559 resp, err := a.convo.SendMessage(userMessage)
1560 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001561 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001562 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001563 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001564 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001565
Sean McCullough96b60dd2025-04-30 09:49:10 -07001566 // Transition to processing LLM response state
1567 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1568
Sean McCullough885a16a2025-04-30 02:49:25 +00001569 return resp, nil
1570}
1571
1572// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001573func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1574 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001575 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001576 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001577
Sean McCullough96b60dd2025-04-30 09:49:10 -07001578 // Transition to checking for cancellation state
1579 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1580
Sean McCullough885a16a2025-04-30 02:49:25 +00001581 // Check if the operation was cancelled by the user
1582 select {
1583 case <-ctx.Done():
1584 // Don't actually run any of the tools, but rather build a response
1585 // for each tool_use message letting the LLM know that user canceled it.
1586 var err error
1587 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001588 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001589 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001590 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001591 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001592 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001593 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001594 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001595 // Transition to running tool state
1596 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1597
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001598 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001599 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001600 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001601
1602 // Execute the tools
1603 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001604 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001605 if ctx.Err() != nil { // e.g. the user canceled the operation
1606 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001607 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001608 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001609 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001610 a.pushToOutbox(ctx, errorMessage(err))
1611 }
1612 }
1613
1614 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001615 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001616 autoqualityMessages := a.processGitChanges(ctx)
1617
1618 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001619 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001620 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001621 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001622 return false, nil
1623 }
1624
1625 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001626 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1627 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001628}
1629
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001630// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001631func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001632 // Check for git commits
1633 _, err := a.handleGitCommits(ctx)
1634 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001635 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001636 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001637 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001638 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001639}
1640
1641// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1642// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001643func (a *Agent) processGitChanges(ctx context.Context) []string {
1644 // Check for git commits after tool execution
1645 newCommits, err := a.handleGitCommits(ctx)
1646 if err != nil {
1647 // Just log the error, don't stop execution
1648 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1649 return nil
1650 }
1651
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001652 // Run mechanical checks if there was exactly one new commit.
1653 if len(newCommits) != 1 {
1654 return nil
1655 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001656 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001657 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1658 msg := a.codereview.RunMechanicalChecks(ctx)
1659 if msg != "" {
1660 a.pushToOutbox(ctx, AgentMessage{
1661 Type: AutoMessageType,
1662 Content: msg,
1663 Timestamp: time.Now(),
1664 })
1665 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001666 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001667
1668 return autoqualityMessages
1669}
1670
1671// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001672func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001673 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001674 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001675 msgs, err := a.GatherMessages(ctx, false)
1676 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001677 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001678 return false, nil
1679 }
1680
1681 // Inject any auto-generated messages from quality checks
1682 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001683 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001684 }
1685
1686 // Handle cancellation by appending a message about it
1687 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001688 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001689 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001690 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001691 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1692 } else if err := a.convo.OverBudget(); err != nil {
1693 // Handle budget issues by appending a message about it
1694 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 -07001695 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001696 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1697 }
1698
1699 // Combine tool results with user messages
1700 results = append(results, msgs...)
1701
1702 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001703 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001704 resp, err := a.convo.SendMessage(llm.Message{
1705 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001706 Content: results,
1707 })
1708 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001709 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001710 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1711 return true, nil // Return true to continue the conversation, but with no response
1712 }
1713
Sean McCullough96b60dd2025-04-30 09:49:10 -07001714 // Transition back to processing LLM response
1715 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1716
Sean McCullough885a16a2025-04-30 02:49:25 +00001717 if cancelled {
1718 return false, nil
1719 }
1720
1721 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001722}
1723
1724func (a *Agent) overBudget(ctx context.Context) error {
1725 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001726 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001727 m := budgetMessage(err)
1728 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001729 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001730 a.convo.ResetBudget(a.originalBudget)
1731 return err
1732 }
1733 return nil
1734}
1735
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001736func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001737 // Collect all text content
1738 var allText strings.Builder
1739 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001740 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001741 if allText.Len() > 0 {
1742 allText.WriteString("\n\n")
1743 }
1744 allText.WriteString(content.Text)
1745 }
1746 }
1747 return allText.String()
1748}
1749
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001750func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001751 a.mu.Lock()
1752 defer a.mu.Unlock()
1753 return a.convo.CumulativeUsage()
1754}
1755
Earl Lee2e463fb2025-04-17 11:22:22 -07001756// Diff returns a unified diff of changes made since the agent was instantiated.
1757func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001758 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001759 return "", fmt.Errorf("no initial commit reference available")
1760 }
1761
1762 // Find the repository root
1763 ctx := context.Background()
1764
1765 // If a specific commit hash is provided, show just that commit's changes
1766 if commit != nil && *commit != "" {
1767 // Validate that the commit looks like a valid git SHA
1768 if !isValidGitSHA(*commit) {
1769 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1770 }
1771
1772 // Get the diff for just this commit
1773 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1774 cmd.Dir = a.repoRoot
1775 output, err := cmd.CombinedOutput()
1776 if err != nil {
1777 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1778 }
1779 return string(output), nil
1780 }
1781
1782 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001783 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001784 cmd.Dir = a.repoRoot
1785 output, err := cmd.CombinedOutput()
1786 if err != nil {
1787 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1788 }
1789
1790 return string(output), nil
1791}
1792
Philip Zeyliger49edc922025-05-14 09:45:45 -07001793// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1794// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1795func (a *Agent) SketchGitBaseRef() string {
1796 if a.IsInContainer() {
1797 return "sketch-base"
1798 } else {
1799 return "sketch-base-" + a.SessionID()
1800 }
1801}
1802
1803// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1804func (a *Agent) SketchGitBase() string {
1805 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1806 cmd.Dir = a.repoRoot
1807 output, err := cmd.CombinedOutput()
1808 if err != nil {
1809 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1810 return "HEAD"
1811 }
1812 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001813}
1814
Pokey Rule7a113622025-05-12 10:58:45 +01001815// removeGitHooks removes the Git hooks directory from the repository
1816func removeGitHooks(_ context.Context, repoPath string) error {
1817 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1818
1819 // Check if hooks directory exists
1820 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1821 // Directory doesn't exist, nothing to do
1822 return nil
1823 }
1824
1825 // Remove the hooks directory
1826 err := os.RemoveAll(hooksDir)
1827 if err != nil {
1828 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1829 }
1830
1831 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001832 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001833 if err != nil {
1834 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1835 }
1836
1837 return nil
1838}
1839
Philip Zeyligerf2872992025-05-22 10:35:28 -07001840func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1841 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef())
1842 for _, msg := range msgs {
1843 a.pushToOutbox(ctx, msg)
1844 }
1845 return commits, error
1846}
1847
Earl Lee2e463fb2025-04-17 11:22:22 -07001848// handleGitCommits() highlights new commits to the user. When running
1849// under docker, new HEADs are pushed to a branch according to the title.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001850func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string) ([]AgentMessage, []*GitCommit, error) {
1851 ags.mu.Lock()
1852 defer ags.mu.Unlock()
1853
1854 msgs := []AgentMessage{}
1855 if repoRoot == "" {
1856 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001857 }
1858
Philip Zeyligerf2872992025-05-22 10:35:28 -07001859 head, err := resolveRef(ctx, repoRoot, "HEAD")
Earl Lee2e463fb2025-04-17 11:22:22 -07001860 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001861 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001862 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001863 if head == ags.lastHEAD {
1864 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001865 }
1866 defer func() {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001867 ags.lastHEAD = head
Earl Lee2e463fb2025-04-17 11:22:22 -07001868 }()
1869
1870 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1871 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1872 // to the last 100 commits.
1873 var commits []*GitCommit
1874
1875 // Get commits since the initial commit
1876 // Format: <hash>\0<subject>\0<body>\0
1877 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1878 // Limit to 100 commits to avoid overwhelming the user
Philip Zeyligerf2872992025-05-22 10:35:28 -07001879 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, head)
1880 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001881 output, err := cmd.Output()
1882 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001883 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001884 }
1885
1886 // Parse git log output and filter out already seen commits
1887 parsedCommits := parseGitLog(string(output))
1888
1889 var headCommit *GitCommit
1890
1891 // Filter out commits we've already seen
1892 for _, commit := range parsedCommits {
1893 if commit.Hash == head {
1894 headCommit = &commit
1895 }
1896
1897 // Skip if we've seen this commit before. If our head has changed, always include that.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001898 if ags.seenCommits[commit.Hash] && commit.Hash != head {
Earl Lee2e463fb2025-04-17 11:22:22 -07001899 continue
1900 }
1901
1902 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001903 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001904
1905 // Add to our list of new commits
1906 commits = append(commits, &commit)
1907 }
1908
Philip Zeyligerf2872992025-05-22 10:35:28 -07001909 if ags.gitRemoteAddr != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001910 if headCommit == nil {
1911 // I think this can only happen if we have a bug or if there's a race.
1912 headCommit = &GitCommit{}
1913 headCommit.Hash = head
1914 headCommit.Subject = "unknown"
1915 commits = append(commits, headCommit)
1916 }
1917
Philip Zeyligerf2872992025-05-22 10:35:28 -07001918 originalBranch := cmp.Or(ags.branchName, "sketch/"+sessionID)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001919 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001920
1921 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1922 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1923 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001924
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001925 // Parse the original branch name to extract base name and starting number
1926 baseBranch, startNum := parseBranchNameAndNumber(originalBranch)
1927
Philip Zeyliger113e2052025-05-09 21:59:40 +00001928 // Try up to 10 times with different branch names if the branch is checked out on the remote
1929 var out []byte
1930 var err error
1931 for retries := range 10 {
1932 if retries > 0 {
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001933 // Increment from the starting number (foo1->foo2, foo2->foo3, etc.)
1934 branch = fmt.Sprintf("%s%d", baseBranch, startNum+retries)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001935 }
1936
Philip Zeyligerf2872992025-05-22 10:35:28 -07001937 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1938 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001939 out, err = cmd.CombinedOutput()
1940
1941 if err == nil {
1942 // Success! Break out of the retry loop
1943 break
1944 }
1945
1946 // Check if this is the "refusing to update checked out branch" error
1947 if !strings.Contains(string(out), "refusing to update checked out branch") {
1948 // This is a different error, so don't retry
1949 break
1950 }
1951
1952 // If we're on the last retry, we'll report the error
1953 if retries == 9 {
1954 break
1955 }
1956 }
1957
1958 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001959 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001960 } else {
1961 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001962 // Update the agent's branch name if we ended up using a different one
1963 if branch != originalBranch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001964 ags.branchName = branch
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001965 // Notify user why the branch name was changed
1966 msgs = append(msgs, AgentMessage{
1967 Type: AutoMessageType,
1968 Timestamp: time.Now(),
1969 Content: fmt.Sprintf("Branch renamed from %s to %s because the original branch is currently checked out on the remote.", originalBranch, branch),
1970 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00001971 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001972 }
1973 }
1974
1975 // If we found new commits, create a message
1976 if len(commits) > 0 {
1977 msg := AgentMessage{
1978 Type: CommitMessageType,
1979 Timestamp: time.Now(),
1980 Commits: commits,
1981 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001982 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001983 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001984 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001985}
1986
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001987func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001988 return strings.Map(func(r rune) rune {
1989 // lowercase
1990 if r >= 'A' && r <= 'Z' {
1991 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001992 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001993 // replace spaces with dashes
1994 if r == ' ' {
1995 return '-'
1996 }
1997 // allow alphanumerics and dashes
1998 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1999 return r
2000 }
2001 return -1
2002 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002003}
2004
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002005// parseBranchNameAndNumber extracts the base branch name and starting number.
2006// For "sketch/foo1" returns ("sketch/foo", 1)
2007// For "sketch/foo" returns ("sketch/foo", 0)
2008func parseBranchNameAndNumber(branchName string) (baseBranch string, startNum int) {
2009 re := regexp.MustCompile(`^(.+?)(\d+)$`)
2010 matches := re.FindStringSubmatch(branchName)
2011
2012 if len(matches) != 3 {
2013 // No trailing digits found
2014 return branchName, 0
2015 }
2016
2017 num, err := strconv.Atoi(matches[2])
2018 if err != nil {
2019 // If parsing fails, treat as no number
2020 return branchName, 0
2021 }
2022
2023 return matches[1], num
2024}
2025
Earl Lee2e463fb2025-04-17 11:22:22 -07002026// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2027// and returns an array of GitCommit structs.
2028func parseGitLog(output string) []GitCommit {
2029 var commits []GitCommit
2030
2031 // No output means no commits
2032 if len(output) == 0 {
2033 return commits
2034 }
2035
2036 // Split by NULL byte
2037 parts := strings.Split(output, "\x00")
2038
2039 // Process in triplets (hash, subject, body)
2040 for i := 0; i < len(parts); i++ {
2041 // Skip empty parts
2042 if parts[i] == "" {
2043 continue
2044 }
2045
2046 // This should be a hash
2047 hash := strings.TrimSpace(parts[i])
2048
2049 // Make sure we have at least a subject part available
2050 if i+1 >= len(parts) {
2051 break // No more parts available
2052 }
2053
2054 // Get the subject
2055 subject := strings.TrimSpace(parts[i+1])
2056
2057 // Get the body if available
2058 body := ""
2059 if i+2 < len(parts) {
2060 body = strings.TrimSpace(parts[i+2])
2061 }
2062
2063 // Skip to the next triplet
2064 i += 2
2065
2066 commits = append(commits, GitCommit{
2067 Hash: hash,
2068 Subject: subject,
2069 Body: body,
2070 })
2071 }
2072
2073 return commits
2074}
2075
2076func repoRoot(ctx context.Context, dir string) (string, error) {
2077 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2078 stderr := new(strings.Builder)
2079 cmd.Stderr = stderr
2080 cmd.Dir = dir
2081 out, err := cmd.Output()
2082 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002083 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002084 }
2085 return strings.TrimSpace(string(out)), nil
2086}
2087
2088func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2089 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2090 stderr := new(strings.Builder)
2091 cmd.Stderr = stderr
2092 cmd.Dir = dir
2093 out, err := cmd.Output()
2094 if err != nil {
2095 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2096 }
2097 // TODO: validate that out is valid hex
2098 return strings.TrimSpace(string(out)), nil
2099}
2100
2101// isValidGitSHA validates if a string looks like a valid git SHA hash.
2102// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2103func isValidGitSHA(sha string) bool {
2104 // Git SHA must be a hexadecimal string with at least 4 characters
2105 if len(sha) < 4 || len(sha) > 40 {
2106 return false
2107 }
2108
2109 // Check if the string only contains hexadecimal characters
2110 for _, char := range sha {
2111 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2112 return false
2113 }
2114 }
2115
2116 return true
2117}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002118
2119// getGitOrigin returns the URL of the git remote 'origin' if it exists
2120func getGitOrigin(ctx context.Context, dir string) string {
2121 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2122 cmd.Dir = dir
2123 stderr := new(strings.Builder)
2124 cmd.Stderr = stderr
2125 out, err := cmd.Output()
2126 if err != nil {
2127 return ""
2128 }
2129 return strings.TrimSpace(string(out))
2130}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002131
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002132// systemPromptData contains the data used to render the system prompt template
2133type systemPromptData struct {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002134 ClientGOOS string
2135 ClientGOARCH string
2136 WorkingDir string
2137 RepoRoot string
2138 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002139 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002140}
2141
2142// renderSystemPrompt renders the system prompt template.
2143func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002144 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002145 ClientGOOS: a.config.ClientGOOS,
2146 ClientGOARCH: a.config.ClientGOARCH,
2147 WorkingDir: a.workingDir,
2148 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002149 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002150 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002151 }
2152
2153 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2154 if err != nil {
2155 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2156 }
2157 buf := new(strings.Builder)
2158 err = tmpl.Execute(buf, data)
2159 if err != nil {
2160 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2161 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002162 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002163 return buf.String()
2164}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002165
2166// StateTransitionIterator provides an iterator over state transitions.
2167type StateTransitionIterator interface {
2168 // Next blocks until a new state transition is available or context is done.
2169 // Returns nil if the context is cancelled.
2170 Next() *StateTransition
2171 // Close removes the listener and cleans up resources.
2172 Close()
2173}
2174
2175// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2176type StateTransitionIteratorImpl struct {
2177 agent *Agent
2178 ctx context.Context
2179 ch chan StateTransition
2180 unsubscribe func()
2181}
2182
2183// Next blocks until a new state transition is available or the context is cancelled.
2184func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2185 select {
2186 case <-s.ctx.Done():
2187 return nil
2188 case transition, ok := <-s.ch:
2189 if !ok {
2190 return nil
2191 }
2192 transitionCopy := transition
2193 return &transitionCopy
2194 }
2195}
2196
2197// Close removes the listener and cleans up resources.
2198func (s *StateTransitionIteratorImpl) Close() {
2199 if s.unsubscribe != nil {
2200 s.unsubscribe()
2201 s.unsubscribe = nil
2202 }
2203}
2204
2205// NewStateTransitionIterator returns an iterator that receives state transitions.
2206func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2207 a.mu.Lock()
2208 defer a.mu.Unlock()
2209
2210 // Create channel to receive state transitions
2211 ch := make(chan StateTransition, 10)
2212
2213 // Add a listener to the state machine
2214 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2215
2216 return &StateTransitionIteratorImpl{
2217 agent: a,
2218 ctx: ctx,
2219 ch: ch,
2220 unsubscribe: unsubscribe,
2221 }
2222}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002223
2224// setupGitHooks creates or updates git hooks in the specified working directory.
2225func setupGitHooks(workingDir string) error {
2226 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2227
2228 _, err := os.Stat(hooksDir)
2229 if os.IsNotExist(err) {
2230 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2231 }
2232 if err != nil {
2233 return fmt.Errorf("error checking git hooks directory: %w", err)
2234 }
2235
2236 // Define the post-commit hook content
2237 postCommitHook := `#!/bin/bash
2238echo "<post_commit_hook>"
2239echo "Please review this commit message and fix it if it is incorrect."
2240echo "This hook only echos the commit message; it does not modify it."
2241echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2242echo "<last_commit_message>"
2243git log -1 --pretty=%B
2244echo "</last_commit_message>"
2245echo "</post_commit_hook>"
2246`
2247
2248 // Define the prepare-commit-msg hook content
2249 prepareCommitMsgHook := `#!/bin/bash
2250# Add Co-Authored-By and Change-ID trailers to commit messages
2251# Check if these trailers already exist before adding them
2252
2253commit_file="$1"
2254COMMIT_SOURCE="$2"
2255
2256# Skip for merges, squashes, or when using a commit template
2257if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2258 [ "$COMMIT_SOURCE" = "squash" ]; then
2259 exit 0
2260fi
2261
2262commit_msg=$(cat "$commit_file")
2263
2264needs_co_author=true
2265needs_change_id=true
2266
2267# Check if commit message already has Co-Authored-By trailer
2268if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2269 needs_co_author=false
2270fi
2271
2272# Check if commit message already has Change-ID trailer
2273if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2274 needs_change_id=false
2275fi
2276
2277# Only modify if at least one trailer needs to be added
2278if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002279 # Ensure there's a proper blank line before trailers
2280 if [ -s "$commit_file" ]; then
2281 # Check if file ends with newline by reading last character
2282 last_char=$(tail -c 1 "$commit_file")
2283
2284 if [ "$last_char" != "" ]; then
2285 # File doesn't end with newline - add two newlines (complete line + blank line)
2286 echo "" >> "$commit_file"
2287 echo "" >> "$commit_file"
2288 else
2289 # File ends with newline - check if we already have a blank line
2290 last_line=$(tail -1 "$commit_file")
2291 if [ -n "$last_line" ]; then
2292 # Last line has content - add one newline for blank line
2293 echo "" >> "$commit_file"
2294 fi
2295 # If last line is empty, we already have a blank line - don't add anything
2296 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002297 fi
2298
2299 # Add trailers if needed
2300 if [ "$needs_co_author" = true ]; then
2301 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2302 fi
2303
2304 if [ "$needs_change_id" = true ]; then
2305 change_id=$(openssl rand -hex 8)
2306 echo "Change-ID: s${change_id}k" >> "$commit_file"
2307 fi
2308fi
2309`
2310
2311 // Update or create the post-commit hook
2312 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2313 if err != nil {
2314 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2315 }
2316
2317 // Update or create the prepare-commit-msg hook
2318 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2319 if err != nil {
2320 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2321 }
2322
2323 return nil
2324}
2325
2326// updateOrCreateHook creates a new hook file or updates an existing one
2327// by appending the new content if it doesn't already contain it.
2328func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2329 // Check if the hook already exists
2330 buf, err := os.ReadFile(hookPath)
2331 if os.IsNotExist(err) {
2332 // Hook doesn't exist, create it
2333 err = os.WriteFile(hookPath, []byte(content), 0o755)
2334 if err != nil {
2335 return fmt.Errorf("failed to create hook: %w", err)
2336 }
2337 return nil
2338 }
2339 if err != nil {
2340 return fmt.Errorf("error reading existing hook: %w", err)
2341 }
2342
2343 // Hook exists, check if our content is already in it by looking for a distinctive line
2344 code := string(buf)
2345 if strings.Contains(code, distinctiveLine) {
2346 // Already contains our content, nothing to do
2347 return nil
2348 }
2349
2350 // Append our content to the existing hook
2351 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2352 if err != nil {
2353 return fmt.Errorf("failed to open hook for appending: %w", err)
2354 }
2355 defer f.Close()
2356
2357 // Ensure there's a newline at the end of the existing content if needed
2358 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2359 _, err = f.WriteString("\n")
2360 if err != nil {
2361 return fmt.Errorf("failed to add newline to hook: %w", err)
2362 }
2363 }
2364
2365 // Add a separator before our content
2366 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2367 if err != nil {
2368 return fmt.Errorf("failed to append to hook: %w", err)
2369 }
2370
2371 return nil
2372}
Sean McCullough138ec242025-06-02 22:42:06 +00002373
2374// GetPortMonitor returns the port monitor instance for accessing port events
2375func (a *Agent) GetPortMonitor() *PortMonitor {
2376 return a.portMonitor
2377}