blob: fd01f339562ebafe7664095c70ed0a543e291b45 [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 Zeyligerb7c58752025-05-01 10:10:17 -070039type MessageIterator interface {
40 // Next blocks until the next message is available. It may
41 // return nil if the underlying iterator context is done.
42 Next() *AgentMessage
43 Close()
44}
45
Earl Lee2e463fb2025-04-17 11:22:22 -070046type CodingAgent interface {
47 // Init initializes an agent inside a docker container.
48 Init(AgentInit) error
49
50 // Ready returns a channel closed after Init successfully called.
51 Ready() <-chan struct{}
52
53 // URL reports the HTTP URL of this agent.
54 URL() string
55
56 // UserMessage enqueues a message to the agent and returns immediately.
57 UserMessage(ctx context.Context, msg string)
58
Philip Zeyligerb7c58752025-05-01 10:10:17 -070059 // Returns an iterator that finishes when the context is done and
60 // starts with the given message index.
61 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070062
Philip Zeyligereab12de2025-05-14 02:35:53 +000063 // Returns an iterator that notifies of state transitions until the context is done.
64 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
65
Earl Lee2e463fb2025-04-17 11:22:22 -070066 // Loop begins the agent loop returns only when ctx is cancelled.
67 Loop(ctx context.Context)
68
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000069 // BranchPrefix returns the configured branch prefix
70 BranchPrefix() string
71
Sean McCulloughedc88dc2025-04-30 02:55:01 +000072 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070073
74 CancelToolUse(toolUseID string, cause error) error
75
76 // Returns a subset of the agent's message history.
77 Messages(start int, end int) []AgentMessage
78
79 // Returns the current number of messages in the history
80 MessageCount() int
81
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070082 TotalUsage() conversation.CumulativeUsage
83 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070084
Earl Lee2e463fb2025-04-17 11:22:22 -070085 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000086 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070087
88 // Diff returns a unified diff of changes made since the agent was instantiated.
89 // If commit is non-nil, it shows the diff for just that specific commit.
90 Diff(commit *string) (string, error)
91
Philip Zeyliger49edc922025-05-14 09:45:45 -070092 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
93 // starts out as the commit where sketch started, but a user can move it if need
94 // be, for example in the case of a rebase. It is stored as a git tag.
95 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070096
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000097 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
98 // (Typically, this is "sketch-base")
99 SketchGitBaseRef() string
100
Earl Lee2e463fb2025-04-17 11:22:22 -0700101 // Title returns the current title of the conversation.
102 Title() string
103
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000104 // BranchName returns the git branch name for the conversation.
105 BranchName() string
106
Earl Lee2e463fb2025-04-17 11:22:22 -0700107 // OS returns the operating system of the client.
108 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000109
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000110 // SessionID returns the unique session identifier.
111 SessionID() string
112
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000113 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700114 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000115
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000116 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
117 OutstandingLLMCallCount() int
118
119 // OutstandingToolCalls returns the names of outstanding tool calls.
120 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000121 OutsideOS() string
122 OutsideHostname() string
123 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000124 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000125 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
126 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700127
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700128 // IsInContainer returns true if the agent is running in a container
129 IsInContainer() bool
130 // FirstMessageIndex returns the index of the first message in the current conversation
131 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700132
133 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700134 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
135 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700136
137 // CompactConversation compacts the current conversation by generating a summary
138 // and restarting the conversation with that summary as the initial context
139 CompactConversation(ctx context.Context) error
Sean McCullough138ec242025-06-02 22:42:06 +0000140 // GetPortMonitor returns the port monitor instance for accessing port events
141 GetPortMonitor() *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700142}
143
144type CodingAgentMessageType string
145
146const (
147 UserMessageType CodingAgentMessageType = "user"
148 AgentMessageType CodingAgentMessageType = "agent"
149 ErrorMessageType CodingAgentMessageType = "error"
150 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
151 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700152 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
153 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
154 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700155
156 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
157)
158
159type AgentMessage struct {
160 Type CodingAgentMessageType `json:"type"`
161 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
162 EndOfTurn bool `json:"end_of_turn"`
163
164 Content string `json:"content"`
165 ToolName string `json:"tool_name,omitempty"`
166 ToolInput string `json:"input,omitempty"`
167 ToolResult string `json:"tool_result,omitempty"`
168 ToolError bool `json:"tool_error,omitempty"`
169 ToolCallId string `json:"tool_call_id,omitempty"`
170
171 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
172 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
173
Sean McCulloughd9f13372025-04-21 15:08:49 -0700174 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
175 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
176
Earl Lee2e463fb2025-04-17 11:22:22 -0700177 // Commits is a list of git commits for a commit message
178 Commits []*GitCommit `json:"commits,omitempty"`
179
180 Timestamp time.Time `json:"timestamp"`
181 ConversationID string `json:"conversation_id"`
182 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700183 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700184
185 // Message timing information
186 StartTime *time.Time `json:"start_time,omitempty"`
187 EndTime *time.Time `json:"end_time,omitempty"`
188 Elapsed *time.Duration `json:"elapsed,omitempty"`
189
190 // Turn duration - the time taken for a complete agent turn
191 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
192
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000193 // HideOutput indicates that this message should not be rendered in the UI.
194 // This is useful for subconversations that generate output that shouldn't be shown to the user.
195 HideOutput bool `json:"hide_output,omitempty"`
196
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700197 // TodoContent contains the agent's todo file content when it has changed
198 TodoContent *string `json:"todo_content,omitempty"`
199
Earl Lee2e463fb2025-04-17 11:22:22 -0700200 Idx int `json:"idx"`
201}
202
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000203// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700204func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700205 if convo == nil {
206 m.ConversationID = ""
207 m.ParentConversationID = nil
208 return
209 }
210 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000211 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700212 if convo.Parent != nil {
213 m.ParentConversationID = &convo.Parent.ID
214 }
215}
216
Earl Lee2e463fb2025-04-17 11:22:22 -0700217// GitCommit represents a single git commit for a commit message
218type GitCommit struct {
219 Hash string `json:"hash"` // Full commit hash
220 Subject string `json:"subject"` // Commit subject line
221 Body string `json:"body"` // Full commit message body
222 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
223}
224
225// ToolCall represents a single tool call within an agent message
226type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700227 Name string `json:"name"`
228 Input string `json:"input"`
229 ToolCallId string `json:"tool_call_id"`
230 ResultMessage *AgentMessage `json:"result_message,omitempty"`
231 Args string `json:"args,omitempty"`
232 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700233}
234
235func (a *AgentMessage) Attr() slog.Attr {
236 var attrs []any = []any{
237 slog.String("type", string(a.Type)),
238 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700239 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700240 if a.EndOfTurn {
241 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
242 }
243 if a.Content != "" {
244 attrs = append(attrs, slog.String("content", a.Content))
245 }
246 if a.ToolName != "" {
247 attrs = append(attrs, slog.String("tool_name", a.ToolName))
248 }
249 if a.ToolInput != "" {
250 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
251 }
252 if a.Elapsed != nil {
253 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
254 }
255 if a.TurnDuration != nil {
256 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
257 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700258 if len(a.ToolResult) > 0 {
259 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700260 }
261 if a.ToolError {
262 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
263 }
264 if len(a.ToolCalls) > 0 {
265 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
266 for i, tc := range a.ToolCalls {
267 toolCallAttrs = append(toolCallAttrs, slog.Group(
268 fmt.Sprintf("tool_call_%d", i),
269 slog.String("name", tc.Name),
270 slog.String("input", tc.Input),
271 ))
272 }
273 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
274 }
275 if a.ConversationID != "" {
276 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
277 }
278 if a.ParentConversationID != nil {
279 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
280 }
281 if a.Usage != nil && !a.Usage.IsZero() {
282 attrs = append(attrs, a.Usage.Attr())
283 }
284 // TODO: timestamp, convo ids, idx?
285 return slog.Group("agent_message", attrs...)
286}
287
288func errorMessage(err error) AgentMessage {
289 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
290 if os.Getenv(("DEBUG")) == "1" {
291 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
292 }
293
294 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
295}
296
297func budgetMessage(err error) AgentMessage {
298 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
299}
300
301// ConvoInterface defines the interface for conversation interactions
302type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700303 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700304 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700305 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700306 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700307 SendMessage(message llm.Message) (*llm.Response, error)
308 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700309 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000310 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700311 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700312 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700313 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700314}
315
Philip Zeyligerf2872992025-05-22 10:35:28 -0700316// AgentGitState holds the state necessary for pushing to a remote git repo
317// when HEAD changes. If gitRemoteAddr is set, then we push to sketch/
318// any time we notice we need to.
319type AgentGitState struct {
320 mu sync.Mutex // protects following
321 lastHEAD string // hash of the last HEAD that was pushed to the host
322 gitRemoteAddr string // HTTP URL of the host git repo
323 seenCommits map[string]bool // Track git commits we've already seen (by hash)
324 branchName string
325}
326
327func (ags *AgentGitState) SetBranchName(branchName string) {
328 ags.mu.Lock()
329 defer ags.mu.Unlock()
330 ags.branchName = branchName
331}
332
333func (ags *AgentGitState) BranchName() string {
334 ags.mu.Lock()
335 defer ags.mu.Unlock()
336 return ags.branchName
337}
338
Earl Lee2e463fb2025-04-17 11:22:22 -0700339type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700340 convo ConvoInterface
341 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700342 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700343 workingDir string
344 repoRoot string // workingDir may be a subdir of repoRoot
345 url string
346 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000347 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700348 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000349 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700350 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700351 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700352 title string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000353 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700354 // State machine to track agent state
355 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000356 // Outside information
357 outsideHostname string
358 outsideOS string
359 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000360 // URL of the git remote 'origin' if it exists
361 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700362
363 // Time when the current turn started (reset at the beginning of InnerLoop)
364 startOfTurn time.Time
365
366 // Inbox - for messages from the user to the agent.
367 // sent on by UserMessage
368 // . e.g. when user types into the chat textarea
369 // read from by GatherMessages
370 inbox chan string
371
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000372 // protects cancelTurn
373 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700374 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000375 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700376
377 // protects following
378 mu sync.Mutex
379
380 // Stores all messages for this agent
381 history []AgentMessage
382
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700383 // Iterators add themselves here when they're ready to be notified of new messages.
384 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700385
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000386 // Track outstanding LLM call IDs
387 outstandingLLMCalls map[string]struct{}
388
389 // Track outstanding tool calls by ID with their names
390 outstandingToolCalls map[string]string
Sean McCullough364f7412025-06-02 00:55:44 +0000391
392 // Port monitoring
393 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700394}
395
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700396// NewIterator implements CodingAgent.
397func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
398 a.mu.Lock()
399 defer a.mu.Unlock()
400
401 return &MessageIteratorImpl{
402 agent: a,
403 ctx: ctx,
404 nextMessageIdx: nextMessageIdx,
405 ch: make(chan *AgentMessage, 100),
406 }
407}
408
409type MessageIteratorImpl struct {
410 agent *Agent
411 ctx context.Context
412 nextMessageIdx int
413 ch chan *AgentMessage
414 subscribed bool
415}
416
417func (m *MessageIteratorImpl) Close() {
418 m.agent.mu.Lock()
419 defer m.agent.mu.Unlock()
420 // Delete ourselves from the subscribers list
421 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
422 return x == m.ch
423 })
424 close(m.ch)
425}
426
427func (m *MessageIteratorImpl) Next() *AgentMessage {
428 // We avoid subscription at creation to let ourselves catch up to "current state"
429 // before subscribing.
430 if !m.subscribed {
431 m.agent.mu.Lock()
432 if m.nextMessageIdx < len(m.agent.history) {
433 msg := &m.agent.history[m.nextMessageIdx]
434 m.nextMessageIdx++
435 m.agent.mu.Unlock()
436 return msg
437 }
438 // The next message doesn't exist yet, so let's subscribe
439 m.agent.subscribers = append(m.agent.subscribers, m.ch)
440 m.subscribed = true
441 m.agent.mu.Unlock()
442 }
443
444 for {
445 select {
446 case <-m.ctx.Done():
447 m.agent.mu.Lock()
448 // Delete ourselves from the subscribers list
449 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
450 return x == m.ch
451 })
452 m.subscribed = false
453 m.agent.mu.Unlock()
454 return nil
455 case msg, ok := <-m.ch:
456 if !ok {
457 // Close may have been called
458 return nil
459 }
460 if msg.Idx == m.nextMessageIdx {
461 m.nextMessageIdx++
462 return msg
463 }
464 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
465 panic("out of order message")
466 }
467 }
468}
469
Sean McCulloughd9d45812025-04-30 16:53:41 -0700470// Assert that Agent satisfies the CodingAgent interface.
471var _ CodingAgent = &Agent{}
472
473// StateName implements CodingAgent.
474func (a *Agent) CurrentStateName() string {
475 if a.stateMachine == nil {
476 return ""
477 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000478 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700479}
480
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700481// CurrentTodoContent returns the current todo list data as JSON.
482// It returns an empty string if no todos exist.
483func (a *Agent) CurrentTodoContent() string {
484 todoPath := claudetool.TodoFilePath(a.config.SessionID)
485 content, err := os.ReadFile(todoPath)
486 if err != nil {
487 return ""
488 }
489 return string(content)
490}
491
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700492// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
493func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
494 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.
495
496IMPORTANT: 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.
497
498Please create a detailed summary that includes:
499
5001. **User's Request**: What did the user originally ask me to do? What was their goal?
501
5022. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
503
5043. **Key Technical Decisions**: What important technical choices were made during our work and why?
505
5064. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
507
5085. **Next Steps**: What still needs to be done to complete the user's request?
509
5106. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
511
512Focus 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.
513
514Reply with ONLY the summary content - no meta-commentary about creating the summary.`
515
516 userMessage := llm.UserStringMessage(msg)
517 // Use a subconversation with history to get the summary
518 // TODO: We don't have any tools here, so we should have enough tokens
519 // to capture a summary, but we may need to modify the history (e.g., remove
520 // TODO data) to save on some tokens.
521 convo := a.convo.SubConvoWithHistory()
522
523 // Modify the system prompt to provide context about the original task
524 originalSystemPrompt := convo.SystemPrompt
525 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.
526
527Your 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.
528
529Original context: You are working in a coding environment with full access to development tools.`)
530
531 resp, err := convo.SendMessage(userMessage)
532 if err != nil {
533 a.pushToOutbox(ctx, errorMessage(err))
534 return "", err
535 }
536 textContent := collectTextContent(resp)
537
538 // Restore original system prompt (though this subconvo will be discarded)
539 convo.SystemPrompt = originalSystemPrompt
540
541 return textContent, nil
542}
543
544// CompactConversation compacts the current conversation by generating a summary
545// and restarting the conversation with that summary as the initial context
546func (a *Agent) CompactConversation(ctx context.Context) error {
547 summary, err := a.generateConversationSummary(ctx)
548 if err != nil {
549 return fmt.Errorf("failed to generate conversation summary: %w", err)
550 }
551
552 a.mu.Lock()
553
554 // Get usage information before resetting conversation
555 lastUsage := a.convo.LastUsage()
556 contextWindow := a.config.Service.TokenContextWindow()
557 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
558
559 // Reset conversation state but keep all other state (git, working dir, etc.)
560 a.firstMessageIndex = len(a.history)
561 a.convo = a.initConvo()
562
563 a.mu.Unlock()
564
565 // Create informative compaction message with token details
566 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
567 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
568 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
569
570 a.pushToOutbox(ctx, AgentMessage{
571 Type: CompactMessageType,
572 Content: compactionMsg,
573 })
574
575 a.pushToOutbox(ctx, AgentMessage{
576 Type: UserMessageType,
577 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),
578 })
579 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)
580
581 return nil
582}
583
Earl Lee2e463fb2025-04-17 11:22:22 -0700584func (a *Agent) URL() string { return a.url }
585
586// Title returns the current title of the conversation.
587// If no title has been set, returns an empty string.
588func (a *Agent) Title() string {
589 a.mu.Lock()
590 defer a.mu.Unlock()
591 return a.title
592}
593
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000594// BranchName returns the git branch name for the conversation.
595func (a *Agent) BranchName() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700596 return a.gitState.BranchName()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000597}
598
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000599// OutstandingLLMCallCount returns the number of outstanding LLM calls.
600func (a *Agent) OutstandingLLMCallCount() int {
601 a.mu.Lock()
602 defer a.mu.Unlock()
603 return len(a.outstandingLLMCalls)
604}
605
606// OutstandingToolCalls returns the names of outstanding tool calls.
607func (a *Agent) OutstandingToolCalls() []string {
608 a.mu.Lock()
609 defer a.mu.Unlock()
610
611 tools := make([]string, 0, len(a.outstandingToolCalls))
612 for _, toolName := range a.outstandingToolCalls {
613 tools = append(tools, toolName)
614 }
615 return tools
616}
617
Earl Lee2e463fb2025-04-17 11:22:22 -0700618// OS returns the operating system of the client.
619func (a *Agent) OS() string {
620 return a.config.ClientGOOS
621}
622
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000623func (a *Agent) SessionID() string {
624 return a.config.SessionID
625}
626
Philip Zeyliger18532b22025-04-23 21:11:46 +0000627// OutsideOS returns the operating system of the outside system.
628func (a *Agent) OutsideOS() string {
629 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000630}
631
Philip Zeyliger18532b22025-04-23 21:11:46 +0000632// OutsideHostname returns the hostname of the outside system.
633func (a *Agent) OutsideHostname() string {
634 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000635}
636
Philip Zeyliger18532b22025-04-23 21:11:46 +0000637// OutsideWorkingDir returns the working directory on the outside system.
638func (a *Agent) OutsideWorkingDir() string {
639 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000640}
641
642// GitOrigin returns the URL of the git remote 'origin' if it exists.
643func (a *Agent) GitOrigin() string {
644 return a.gitOrigin
645}
646
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000647func (a *Agent) OpenBrowser(url string) {
648 if !a.IsInContainer() {
649 browser.Open(url)
650 return
651 }
652 // We're in Docker, need to send a request to the Git server
653 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700654 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000655 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700656 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000657 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700658 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000659 return
660 }
661 defer resp.Body.Close()
662 if resp.StatusCode == http.StatusOK {
663 return
664 }
665 body, _ := io.ReadAll(resp.Body)
666 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
667}
668
Sean McCullough96b60dd2025-04-30 09:49:10 -0700669// CurrentState returns the current state of the agent's state machine.
670func (a *Agent) CurrentState() State {
671 return a.stateMachine.CurrentState()
672}
673
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700674func (a *Agent) IsInContainer() bool {
675 return a.config.InDocker
676}
677
678func (a *Agent) FirstMessageIndex() int {
679 a.mu.Lock()
680 defer a.mu.Unlock()
681 return a.firstMessageIndex
682}
683
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000684// SetTitle sets the title of the conversation.
685func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700686 a.mu.Lock()
687 defer a.mu.Unlock()
688 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000689}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700690
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000691// SetBranch sets the branch name of the conversation.
692func (a *Agent) SetBranch(branchName string) {
693 a.mu.Lock()
694 defer a.mu.Unlock()
Philip Zeyligerf2872992025-05-22 10:35:28 -0700695 a.gitState.SetBranchName(branchName)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000696 convo, ok := a.convo.(*conversation.Convo)
697 if ok {
698 convo.ExtraData["branch"] = branchName
699 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700700}
701
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000702// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700703func (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 +0000704 // Track the tool call
705 a.mu.Lock()
706 a.outstandingToolCalls[id] = toolName
707 a.mu.Unlock()
708}
709
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700710// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
711// If there's only one element in the array and it's a text type, it returns that text directly.
712// It also processes nested ToolResult arrays recursively.
713func contentToString(contents []llm.Content) string {
714 if len(contents) == 0 {
715 return ""
716 }
717
718 // If there's only one element and it's a text type, return it directly
719 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
720 return contents[0].Text
721 }
722
723 // Otherwise, concatenate all text content
724 var result strings.Builder
725 for _, content := range contents {
726 if content.Type == llm.ContentTypeText {
727 result.WriteString(content.Text)
728 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
729 // Recursively process nested tool results
730 result.WriteString(contentToString(content.ToolResult))
731 }
732 }
733
734 return result.String()
735}
736
Earl Lee2e463fb2025-04-17 11:22:22 -0700737// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700738func (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 +0000739 // Remove the tool call from outstanding calls
740 a.mu.Lock()
741 delete(a.outstandingToolCalls, toolID)
742 a.mu.Unlock()
743
Earl Lee2e463fb2025-04-17 11:22:22 -0700744 m := AgentMessage{
745 Type: ToolUseMessageType,
746 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700747 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700748 ToolError: content.ToolError,
749 ToolName: toolName,
750 ToolInput: string(toolInput),
751 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700752 StartTime: content.ToolUseStartTime,
753 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700754 }
755
756 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700757 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
758 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700759 m.Elapsed = &elapsed
760 }
761
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700762 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700763 a.pushToOutbox(ctx, m)
764}
765
766// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700767func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000768 a.mu.Lock()
769 defer a.mu.Unlock()
770 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700771 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
772}
773
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700774// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700775// that need to be displayed (as well as tool calls that we send along when
776// they're done). (It would be reasonable to also mention tool calls when they're
777// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700778func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000779 // Remove the LLM call from outstanding calls
780 a.mu.Lock()
781 delete(a.outstandingLLMCalls, id)
782 a.mu.Unlock()
783
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700784 if resp == nil {
785 // LLM API call failed
786 m := AgentMessage{
787 Type: ErrorMessageType,
788 Content: "API call failed, type 'continue' to try again",
789 }
790 m.SetConvo(convo)
791 a.pushToOutbox(ctx, m)
792 return
793 }
794
Earl Lee2e463fb2025-04-17 11:22:22 -0700795 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700796 if convo.Parent == nil { // subconvos never end the turn
797 switch resp.StopReason {
798 case llm.StopReasonToolUse:
799 // Check whether any of the tool calls are for tools that should end the turn
800 ToolSearch:
801 for _, part := range resp.Content {
802 if part.Type != llm.ContentTypeToolUse {
803 continue
804 }
Sean McCullough021557a2025-05-05 23:20:53 +0000805 // Find the tool by name
806 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700807 if tool.Name == part.ToolName {
808 endOfTurn = tool.EndsTurn
809 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000810 }
811 }
Sean McCullough021557a2025-05-05 23:20:53 +0000812 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700813 default:
814 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000815 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700816 }
817 m := AgentMessage{
818 Type: AgentMessageType,
819 Content: collectTextContent(resp),
820 EndOfTurn: endOfTurn,
821 Usage: &resp.Usage,
822 StartTime: resp.StartTime,
823 EndTime: resp.EndTime,
824 }
825
826 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700827 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700828 var toolCalls []ToolCall
829 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700830 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700831 toolCalls = append(toolCalls, ToolCall{
832 Name: part.ToolName,
833 Input: string(part.ToolInput),
834 ToolCallId: part.ID,
835 })
836 }
837 }
838 m.ToolCalls = toolCalls
839 }
840
841 // Calculate the elapsed time if both start and end times are set
842 if resp.StartTime != nil && resp.EndTime != nil {
843 elapsed := resp.EndTime.Sub(*resp.StartTime)
844 m.Elapsed = &elapsed
845 }
846
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700847 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700848 a.pushToOutbox(ctx, m)
849}
850
851// WorkingDir implements CodingAgent.
852func (a *Agent) WorkingDir() string {
853 return a.workingDir
854}
855
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000856// RepoRoot returns the git repository root directory.
857func (a *Agent) RepoRoot() string {
858 return a.repoRoot
859}
860
Earl Lee2e463fb2025-04-17 11:22:22 -0700861// MessageCount implements CodingAgent.
862func (a *Agent) MessageCount() int {
863 a.mu.Lock()
864 defer a.mu.Unlock()
865 return len(a.history)
866}
867
868// Messages implements CodingAgent.
869func (a *Agent) Messages(start int, end int) []AgentMessage {
870 a.mu.Lock()
871 defer a.mu.Unlock()
872 return slices.Clone(a.history[start:end])
873}
874
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700875// ShouldCompact checks if the conversation should be compacted based on token usage
876func (a *Agent) ShouldCompact() bool {
877 // Get the threshold from environment variable, default to 0.94 (94%)
878 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
879 // and a little bit of buffer.)
880 thresholdRatio := 0.94
881 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
882 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
883 thresholdRatio = parsed
884 }
885 }
886
887 // Get the most recent usage to check current context size
888 lastUsage := a.convo.LastUsage()
889
890 if lastUsage.InputTokens == 0 {
891 // No API calls made yet
892 return false
893 }
894
895 // Calculate the current context size from the last API call
896 // This includes all tokens that were part of the input context:
897 // - Input tokens (user messages, system prompt, conversation history)
898 // - Cache read tokens (cached parts of the context)
899 // - Cache creation tokens (new parts being cached)
900 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
901
902 // Get the service's token context window
903 service := a.config.Service
904 contextWindow := service.TokenContextWindow()
905
906 // Calculate threshold
907 threshold := uint64(float64(contextWindow) * thresholdRatio)
908
909 // Check if we've exceeded the threshold
910 return currentContextSize >= threshold
911}
912
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700913func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700914 return a.originalBudget
915}
916
917// AgentConfig contains configuration for creating a new Agent.
918type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +0000919 Context context.Context
920 Service llm.Service
921 Budget conversation.Budget
922 GitUsername string
923 GitEmail string
924 SessionID string
925 ClientGOOS string
926 ClientGOARCH string
927 InDocker bool
928 OneShot bool
929 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000930 // Outside information
931 OutsideHostname string
932 OutsideOS string
933 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700934
935 // Outtie's HTTP to, e.g., open a browser
936 OutsideHTTP string
937 // Outtie's Git server
938 GitRemoteAddr string
939 // Commit to checkout from Outtie
940 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000941 // Prefix for git branches created by sketch
942 BranchPrefix string
Earl Lee2e463fb2025-04-17 11:22:22 -0700943}
944
945// NewAgent creates a new Agent.
946// It is not usable until Init() is called.
947func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000948 // Set default branch prefix if not specified
949 if config.BranchPrefix == "" {
950 config.BranchPrefix = "sketch/"
951 }
952
Earl Lee2e463fb2025-04-17 11:22:22 -0700953 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -0700954 config: config,
955 ready: make(chan struct{}),
956 inbox: make(chan string, 100),
957 subscribers: make([]chan *AgentMessage, 0),
958 startedAt: time.Now(),
959 originalBudget: config.Budget,
960 gitState: AgentGitState{
961 seenCommits: make(map[string]bool),
962 gitRemoteAddr: config.GitRemoteAddr,
963 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000964 outsideHostname: config.OutsideHostname,
965 outsideOS: config.OutsideOS,
966 outsideWorkingDir: config.OutsideWorkingDir,
967 outstandingLLMCalls: make(map[string]struct{}),
968 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700969 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700970 workingDir: config.WorkingDir,
971 outsideHTTP: config.OutsideHTTP,
Sean McCullough364f7412025-06-02 00:55:44 +0000972 portMonitor: NewPortMonitor(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700973 }
974 return agent
975}
976
977type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700978 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -0700979
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700980 InDocker bool
981 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -0700982}
983
984func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700985 if a.convo != nil {
986 return fmt.Errorf("Agent.Init: already initialized")
987 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700988 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -0700989 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700990
Philip Zeyliger2f0eb692025-06-04 09:53:42 -0700991 if !ini.NoGit {
992 // Capture the original origin before we potentially replace it below
993 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
994 }
995
Philip Zeyliger222bf412025-06-04 16:42:58 +0000996 // If a remote git addr was specified, we configure the origin remote
Philip Zeyligerf2872992025-05-22 10:35:28 -0700997 if a.gitState.gitRemoteAddr != "" {
998 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
Philip Zeyliger222bf412025-06-04 16:42:58 +0000999
1000 // Remove existing origin remote if it exists
1001 cmd := exec.CommandContext(ctx, "git", "remote", "remove", "origin")
Philip Zeyligerf2872992025-05-22 10:35:28 -07001002 cmd.Dir = a.workingDir
1003 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001004 // Ignore error if origin doesn't exist
1005 slog.DebugContext(ctx, "git remote remove origin (ignoring if not exists)", slog.String("output", string(out)))
Philip Zeyligerf2872992025-05-22 10:35:28 -07001006 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001007
1008 // Add the new remote as origin
1009 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", a.gitState.gitRemoteAddr)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001010 cmd.Dir = a.workingDir
1011 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001012 return fmt.Errorf("git remote add origin: %s: %v", out, err)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001013 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001014
Philip Zeyligerf2872992025-05-22 10:35:28 -07001015 }
1016
1017 // If a commit was specified, we fetch and reset to it.
1018 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001019 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1020
Earl Lee2e463fb2025-04-17 11:22:22 -07001021 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001022 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001023 if out, err := cmd.CombinedOutput(); err != nil {
1024 return fmt.Errorf("git stash: %s: %v", out, err)
1025 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001026 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001027 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001028 if out, err := cmd.CombinedOutput(); err != nil {
1029 return fmt.Errorf("git fetch: %s: %w", out, err)
1030 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001031 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1032 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001033 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1034 // Remove git hooks if they exist and retry
1035 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001036 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001037 if _, statErr := os.Stat(hookPath); statErr == nil {
1038 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1039 slog.String("error", err.Error()),
1040 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001041 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001042 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1043 }
1044
1045 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001046 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1047 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001048 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001049 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 +01001050 }
1051 } else {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001052 return fmt.Errorf("git checkout %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001053 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001054 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001055 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001056
1057 if ini.HostAddr != "" {
1058 a.url = "http://" + ini.HostAddr
1059 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001060
1061 if !ini.NoGit {
1062 repoRoot, err := repoRoot(ctx, a.workingDir)
1063 if err != nil {
1064 return fmt.Errorf("repoRoot: %w", err)
1065 }
1066 a.repoRoot = repoRoot
1067
Earl Lee2e463fb2025-04-17 11:22:22 -07001068 if err != nil {
1069 return fmt.Errorf("resolveRef: %w", err)
1070 }
Philip Zeyliger49edc922025-05-14 09:45:45 -07001071
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001072 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001073 if err := setupGitHooks(a.repoRoot); err != nil {
1074 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1075 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001076 }
1077
Philip Zeyliger49edc922025-05-14 09:45:45 -07001078 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1079 cmd.Dir = repoRoot
1080 if out, err := cmd.CombinedOutput(); err != nil {
1081 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1082 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001083
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001084 slog.Info("running codebase analysis")
1085 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1086 if err != nil {
1087 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001088 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001089 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001090
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001091 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001092 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001093 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001094 }
1095 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001096
Earl Lee2e463fb2025-04-17 11:22:22 -07001097 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001098 a.gitState.lastHEAD = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001099 a.convo = a.initConvo()
1100 close(a.ready)
1101 return nil
1102}
1103
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001104//go:embed agent_system_prompt.txt
1105var agentSystemPrompt string
1106
Earl Lee2e463fb2025-04-17 11:22:22 -07001107// initConvo initializes the conversation.
1108// It must not be called until all agent fields are initialized,
1109// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001110func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001111 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001112 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -07001113 convo.PromptCaching = true
1114 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001115 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001116 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001117
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001118 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1119 bashPermissionCheck := func(command string) error {
1120 // Check if branch name is set
1121 a.mu.Lock()
Philip Zeyligerf2872992025-05-22 10:35:28 -07001122 branchSet := a.gitState.BranchName() != ""
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001123 a.mu.Unlock()
1124
1125 // If branch is set, all commands are allowed
1126 if branchSet {
1127 return nil
1128 }
1129
1130 // If branch is not set, check if this is a git commit command
1131 willCommit, err := bashkit.WillRunGitCommit(command)
1132 if err != nil {
1133 // If there's an error checking, we should allow the command to proceed
1134 return nil
1135 }
1136
1137 // If it's a git commit and branch is not set, return an error
1138 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001139 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001140 }
1141
1142 return nil
1143 }
1144
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001145 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001146
Earl Lee2e463fb2025-04-17 11:22:22 -07001147 // Register all tools with the conversation
1148 // When adding, removing, or modifying tools here, double-check that the termui tool display
1149 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001150
1151 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001152 _, supportsScreenshots := a.config.Service.(*ant.Service)
1153 var bTools []*llm.Tool
1154 var browserCleanup func()
1155
1156 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1157 // Add cleanup function to context cancel
1158 go func() {
1159 <-a.config.Context.Done()
1160 browserCleanup()
1161 }()
1162 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001163
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001164 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001165 bashTool, claudetool.Keyword, claudetool.Patch,
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001166 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001167 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001168 }
1169
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001170 // One-shot mode is non-interactive, multiple choice requires human response
1171 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001172 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001173 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001174
1175 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -07001176 convo.Listener = a
1177 return convo
1178}
1179
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001180var multipleChoiceTool = &llm.Tool{
1181 Name: "multiplechoice",
1182 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.",
1183 EndsTurn: true,
1184 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001185 "type": "object",
1186 "description": "The question and a list of answers you would expect the user to choose from.",
1187 "properties": {
1188 "question": {
1189 "type": "string",
1190 "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?'"
1191 },
1192 "responseOptions": {
1193 "type": "array",
1194 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1195 "items": {
1196 "type": "object",
1197 "properties": {
1198 "caption": {
1199 "type": "string",
1200 "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'"
1201 },
1202 "responseText": {
1203 "type": "string",
1204 "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'"
1205 }
1206 },
1207 "required": ["caption", "responseText"]
1208 }
1209 }
1210 },
1211 "required": ["question", "responseOptions"]
1212}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001213 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1214 // The Run logic for "multiplechoice" tool is a no-op on the server.
1215 // The UI will present a list of options for the user to select from,
1216 // and that's it as far as "executing" the tool_use goes.
1217 // When the user *does* select one of the presented options, that
1218 // responseText gets sent as a chat message on behalf of the user.
1219 return llm.TextContent("end your turn and wait for the user to respond"), nil
1220 },
Sean McCullough485afc62025-04-28 14:28:39 -07001221}
1222
1223type MultipleChoiceOption struct {
1224 Caption string `json:"caption"`
1225 ResponseText string `json:"responseText"`
1226}
1227
1228type MultipleChoiceParams struct {
1229 Question string `json:"question"`
1230 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1231}
1232
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001233// branchExists reports whether branchName exists, either locally or in well-known remotes.
1234func branchExists(dir, branchName string) bool {
1235 refs := []string{
1236 "refs/heads/",
1237 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001238 }
1239 for _, ref := range refs {
1240 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1241 cmd.Dir = dir
1242 if cmd.Run() == nil { // exit code 0 means branch exists
1243 return true
1244 }
1245 }
1246 return false
1247}
1248
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001249func (a *Agent) titleTool() *llm.Tool {
1250 description := `Sets the conversation title.`
1251 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001252 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001253 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001254 InputSchema: json.RawMessage(`{
1255 "type": "object",
1256 "properties": {
1257 "title": {
1258 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001259 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001260 }
1261 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001262 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001263}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001264 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001265 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001266 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001267 }
1268 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001269 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001270 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001271
1272 // We don't allow changing the title once set to be consistent with the previous behavior
1273 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001274 t := a.Title()
1275 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001276 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001277 }
1278
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001279 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001280 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001281 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001282
1283 a.SetTitle(params.Title)
1284 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001285 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001286 },
1287 }
1288 return titleTool
1289}
1290
1291func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001292 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 +00001293 preCommit := &llm.Tool{
1294 Name: "precommit",
1295 Description: description,
1296 InputSchema: json.RawMessage(`{
1297 "type": "object",
1298 "properties": {
1299 "branch_name": {
1300 "type": "string",
1301 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1302 }
1303 },
1304 "required": ["branch_name"]
1305}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001306 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001307 var params struct {
1308 BranchName string `json:"branch_name"`
1309 }
1310 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001311 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001312 }
1313
1314 b := a.BranchName()
1315 if b != "" {
Josh Bleecher Snyder44d1f1a2025-05-12 19:18:32 -07001316 return nil, fmt.Errorf("branch already set to %s; do not create a new branch", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001317 }
1318
1319 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001320 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001321 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001322 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001323 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001324 }
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001325 branchName := a.config.BranchPrefix + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001326 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001327 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001328 }
1329
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001330 a.SetBranch(branchName)
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001331 response := fmt.Sprintf("switched to branch %q - DO NOT change branches unless explicitly requested", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001332
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001333 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1334 if err != nil {
1335 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1336 }
1337 if len(styleHint) > 0 {
1338 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001339 }
1340
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001341 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001342 },
1343 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001344 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001345}
1346
1347func (a *Agent) Ready() <-chan struct{} {
1348 return a.ready
1349}
1350
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001351// BranchPrefix returns the configured branch prefix
1352func (a *Agent) BranchPrefix() string {
1353 return a.config.BranchPrefix
1354}
1355
Earl Lee2e463fb2025-04-17 11:22:22 -07001356func (a *Agent) UserMessage(ctx context.Context, msg string) {
1357 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1358 a.inbox <- msg
1359}
1360
Earl Lee2e463fb2025-04-17 11:22:22 -07001361func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1362 return a.convo.CancelToolUse(toolUseID, cause)
1363}
1364
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001365func (a *Agent) CancelTurn(cause error) {
1366 a.cancelTurnMu.Lock()
1367 defer a.cancelTurnMu.Unlock()
1368 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001369 // Force state transition to cancelled state
1370 ctx := a.config.Context
1371 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001372 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001373 }
1374}
1375
1376func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001377 // Start port monitoring when the agent loop begins
1378 // Only monitor ports when running in a container
1379 if a.IsInContainer() {
1380 a.portMonitor.Start(ctxOuter)
1381 }
1382
Earl Lee2e463fb2025-04-17 11:22:22 -07001383 for {
1384 select {
1385 case <-ctxOuter.Done():
1386 return
1387 default:
1388 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001389 a.cancelTurnMu.Lock()
1390 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001391 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001392 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001393 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001394 a.cancelTurn = cancel
1395 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001396 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1397 if err != nil {
1398 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1399 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001400 cancel(nil)
1401 }
1402 }
1403}
1404
1405func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1406 if m.Timestamp.IsZero() {
1407 m.Timestamp = time.Now()
1408 }
1409
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001410 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1411 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1412 m.Content = m.ToolResult
1413 }
1414
Earl Lee2e463fb2025-04-17 11:22:22 -07001415 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1416 if m.EndOfTurn && m.Type == AgentMessageType {
1417 turnDuration := time.Since(a.startOfTurn)
1418 m.TurnDuration = &turnDuration
1419 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1420 }
1421
Earl Lee2e463fb2025-04-17 11:22:22 -07001422 a.mu.Lock()
1423 defer a.mu.Unlock()
1424 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001425 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001426 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001427
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001428 // Notify all subscribers
1429 for _, ch := range a.subscribers {
1430 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001431 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001432}
1433
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001434func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1435 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001436 if block {
1437 select {
1438 case <-ctx.Done():
1439 return m, ctx.Err()
1440 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001441 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001442 }
1443 }
1444 for {
1445 select {
1446 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001447 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001448 default:
1449 return m, nil
1450 }
1451 }
1452}
1453
Sean McCullough885a16a2025-04-30 02:49:25 +00001454// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001455func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001456 // Reset the start of turn time
1457 a.startOfTurn = time.Now()
1458
Sean McCullough96b60dd2025-04-30 09:49:10 -07001459 // Transition to waiting for user input state
1460 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1461
Sean McCullough885a16a2025-04-30 02:49:25 +00001462 // Process initial user message
1463 initialResp, err := a.processUserMessage(ctx)
1464 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001465 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001466 return err
1467 }
1468
1469 // Handle edge case where both initialResp and err are nil
1470 if initialResp == nil {
1471 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001472 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1473
Sean McCullough9f4b8082025-04-30 17:34:07 +00001474 a.pushToOutbox(ctx, errorMessage(err))
1475 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001476 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001477
Earl Lee2e463fb2025-04-17 11:22:22 -07001478 // We do this as we go, but let's also do it at the end of the turn
1479 defer func() {
1480 if _, err := a.handleGitCommits(ctx); err != nil {
1481 // Just log the error, don't stop execution
1482 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1483 }
1484 }()
1485
Sean McCullougha1e0e492025-05-01 10:51:08 -07001486 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001487 resp := initialResp
1488 for {
1489 // Check if we are over budget
1490 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001491 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001492 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001493 }
1494
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001495 // Check if we should compact the conversation
1496 if a.ShouldCompact() {
1497 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1498 if err := a.CompactConversation(ctx); err != nil {
1499 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1500 return err
1501 }
1502 // After compaction, end this turn and start fresh
1503 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1504 return nil
1505 }
1506
Sean McCullough885a16a2025-04-30 02:49:25 +00001507 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001508 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001509 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001510 break
1511 }
1512
Sean McCullough96b60dd2025-04-30 09:49:10 -07001513 // Transition to tool use requested state
1514 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1515
Sean McCullough885a16a2025-04-30 02:49:25 +00001516 // Handle tool execution
1517 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1518 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001519 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001520 }
1521
Sean McCullougha1e0e492025-05-01 10:51:08 -07001522 if toolResp == nil {
1523 return fmt.Errorf("cannot continue conversation with a nil tool response")
1524 }
1525
Sean McCullough885a16a2025-04-30 02:49:25 +00001526 // Set the response for the next iteration
1527 resp = toolResp
1528 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001529
1530 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001531}
1532
1533// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001534func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001535 // Wait for at least one message from the user
1536 msgs, err := a.GatherMessages(ctx, true)
1537 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001538 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001539 return nil, err
1540 }
1541
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001542 userMessage := llm.Message{
1543 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001544 Content: msgs,
1545 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001546
Sean McCullough96b60dd2025-04-30 09:49:10 -07001547 // Transition to sending to LLM state
1548 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1549
Sean McCullough885a16a2025-04-30 02:49:25 +00001550 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001551 resp, err := a.convo.SendMessage(userMessage)
1552 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001553 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001554 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001555 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001556 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001557
Sean McCullough96b60dd2025-04-30 09:49:10 -07001558 // Transition to processing LLM response state
1559 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1560
Sean McCullough885a16a2025-04-30 02:49:25 +00001561 return resp, nil
1562}
1563
1564// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001565func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1566 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001567 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001568 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001569
Sean McCullough96b60dd2025-04-30 09:49:10 -07001570 // Transition to checking for cancellation state
1571 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1572
Sean McCullough885a16a2025-04-30 02:49:25 +00001573 // Check if the operation was cancelled by the user
1574 select {
1575 case <-ctx.Done():
1576 // Don't actually run any of the tools, but rather build a response
1577 // for each tool_use message letting the LLM know that user canceled it.
1578 var err error
1579 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001580 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001581 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001582 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001583 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001584 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001585 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001586 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001587 // Transition to running tool state
1588 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1589
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001590 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001591 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001592 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001593
1594 // Execute the tools
1595 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001596 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001597 if ctx.Err() != nil { // e.g. the user canceled the operation
1598 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001599 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001600 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001601 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001602 a.pushToOutbox(ctx, errorMessage(err))
1603 }
1604 }
1605
1606 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001607 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001608 autoqualityMessages := a.processGitChanges(ctx)
1609
1610 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001611 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001612 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001613 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001614 return false, nil
1615 }
1616
1617 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001618 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1619 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001620}
1621
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001622// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001623func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001624 // Check for git commits
1625 _, err := a.handleGitCommits(ctx)
1626 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001627 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001628 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001629 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001630 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001631}
1632
1633// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1634// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001635func (a *Agent) processGitChanges(ctx context.Context) []string {
1636 // Check for git commits after tool execution
1637 newCommits, err := a.handleGitCommits(ctx)
1638 if err != nil {
1639 // Just log the error, don't stop execution
1640 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1641 return nil
1642 }
1643
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001644 // Run mechanical checks if there was exactly one new commit.
1645 if len(newCommits) != 1 {
1646 return nil
1647 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001648 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001649 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1650 msg := a.codereview.RunMechanicalChecks(ctx)
1651 if msg != "" {
1652 a.pushToOutbox(ctx, AgentMessage{
1653 Type: AutoMessageType,
1654 Content: msg,
1655 Timestamp: time.Now(),
1656 })
1657 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001658 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001659
1660 return autoqualityMessages
1661}
1662
1663// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001664func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001665 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001666 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001667 msgs, err := a.GatherMessages(ctx, false)
1668 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001669 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001670 return false, nil
1671 }
1672
1673 // Inject any auto-generated messages from quality checks
1674 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001675 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001676 }
1677
1678 // Handle cancellation by appending a message about it
1679 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001680 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001681 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001682 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001683 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1684 } else if err := a.convo.OverBudget(); err != nil {
1685 // Handle budget issues by appending a message about it
1686 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 -07001687 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001688 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1689 }
1690
1691 // Combine tool results with user messages
1692 results = append(results, msgs...)
1693
1694 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001695 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001696 resp, err := a.convo.SendMessage(llm.Message{
1697 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001698 Content: results,
1699 })
1700 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001701 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001702 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1703 return true, nil // Return true to continue the conversation, but with no response
1704 }
1705
Sean McCullough96b60dd2025-04-30 09:49:10 -07001706 // Transition back to processing LLM response
1707 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1708
Sean McCullough885a16a2025-04-30 02:49:25 +00001709 if cancelled {
1710 return false, nil
1711 }
1712
1713 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001714}
1715
1716func (a *Agent) overBudget(ctx context.Context) error {
1717 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001718 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001719 m := budgetMessage(err)
1720 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001721 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001722 a.convo.ResetBudget(a.originalBudget)
1723 return err
1724 }
1725 return nil
1726}
1727
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001728func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001729 // Collect all text content
1730 var allText strings.Builder
1731 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001732 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001733 if allText.Len() > 0 {
1734 allText.WriteString("\n\n")
1735 }
1736 allText.WriteString(content.Text)
1737 }
1738 }
1739 return allText.String()
1740}
1741
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001742func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001743 a.mu.Lock()
1744 defer a.mu.Unlock()
1745 return a.convo.CumulativeUsage()
1746}
1747
Earl Lee2e463fb2025-04-17 11:22:22 -07001748// Diff returns a unified diff of changes made since the agent was instantiated.
1749func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001750 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001751 return "", fmt.Errorf("no initial commit reference available")
1752 }
1753
1754 // Find the repository root
1755 ctx := context.Background()
1756
1757 // If a specific commit hash is provided, show just that commit's changes
1758 if commit != nil && *commit != "" {
1759 // Validate that the commit looks like a valid git SHA
1760 if !isValidGitSHA(*commit) {
1761 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1762 }
1763
1764 // Get the diff for just this commit
1765 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1766 cmd.Dir = a.repoRoot
1767 output, err := cmd.CombinedOutput()
1768 if err != nil {
1769 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1770 }
1771 return string(output), nil
1772 }
1773
1774 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001775 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001776 cmd.Dir = a.repoRoot
1777 output, err := cmd.CombinedOutput()
1778 if err != nil {
1779 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1780 }
1781
1782 return string(output), nil
1783}
1784
Philip Zeyliger49edc922025-05-14 09:45:45 -07001785// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1786// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1787func (a *Agent) SketchGitBaseRef() string {
1788 if a.IsInContainer() {
1789 return "sketch-base"
1790 } else {
1791 return "sketch-base-" + a.SessionID()
1792 }
1793}
1794
1795// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1796func (a *Agent) SketchGitBase() string {
1797 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1798 cmd.Dir = a.repoRoot
1799 output, err := cmd.CombinedOutput()
1800 if err != nil {
1801 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1802 return "HEAD"
1803 }
1804 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001805}
1806
Pokey Rule7a113622025-05-12 10:58:45 +01001807// removeGitHooks removes the Git hooks directory from the repository
1808func removeGitHooks(_ context.Context, repoPath string) error {
1809 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1810
1811 // Check if hooks directory exists
1812 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1813 // Directory doesn't exist, nothing to do
1814 return nil
1815 }
1816
1817 // Remove the hooks directory
1818 err := os.RemoveAll(hooksDir)
1819 if err != nil {
1820 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1821 }
1822
1823 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001824 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001825 if err != nil {
1826 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1827 }
1828
1829 return nil
1830}
1831
Philip Zeyligerf2872992025-05-22 10:35:28 -07001832func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001833 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001834 for _, msg := range msgs {
1835 a.pushToOutbox(ctx, msg)
1836 }
1837 return commits, error
1838}
1839
Earl Lee2e463fb2025-04-17 11:22:22 -07001840// handleGitCommits() highlights new commits to the user. When running
1841// under docker, new HEADs are pushed to a branch according to the title.
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001842func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001843 ags.mu.Lock()
1844 defer ags.mu.Unlock()
1845
1846 msgs := []AgentMessage{}
1847 if repoRoot == "" {
1848 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001849 }
1850
Philip Zeyligerf2872992025-05-22 10:35:28 -07001851 head, err := resolveRef(ctx, repoRoot, "HEAD")
Earl Lee2e463fb2025-04-17 11:22:22 -07001852 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001853 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001854 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001855 if head == ags.lastHEAD {
1856 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001857 }
1858 defer func() {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001859 ags.lastHEAD = head
Earl Lee2e463fb2025-04-17 11:22:22 -07001860 }()
1861
1862 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1863 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1864 // to the last 100 commits.
1865 var commits []*GitCommit
1866
1867 // Get commits since the initial commit
1868 // Format: <hash>\0<subject>\0<body>\0
1869 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1870 // Limit to 100 commits to avoid overwhelming the user
Philip Zeyligerf2872992025-05-22 10:35:28 -07001871 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, head)
1872 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001873 output, err := cmd.Output()
1874 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001875 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001876 }
1877
1878 // Parse git log output and filter out already seen commits
1879 parsedCommits := parseGitLog(string(output))
1880
1881 var headCommit *GitCommit
1882
1883 // Filter out commits we've already seen
1884 for _, commit := range parsedCommits {
1885 if commit.Hash == head {
1886 headCommit = &commit
1887 }
1888
1889 // Skip if we've seen this commit before. If our head has changed, always include that.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001890 if ags.seenCommits[commit.Hash] && commit.Hash != head {
Earl Lee2e463fb2025-04-17 11:22:22 -07001891 continue
1892 }
1893
1894 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001895 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001896
1897 // Add to our list of new commits
1898 commits = append(commits, &commit)
1899 }
1900
Philip Zeyligerf2872992025-05-22 10:35:28 -07001901 if ags.gitRemoteAddr != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001902 if headCommit == nil {
1903 // I think this can only happen if we have a bug or if there's a race.
1904 headCommit = &GitCommit{}
1905 headCommit.Hash = head
1906 headCommit.Subject = "unknown"
1907 commits = append(commits, headCommit)
1908 }
1909
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001910 originalBranch := cmp.Or(ags.branchName, branchPrefix+sessionID)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001911 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001912
1913 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1914 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1915 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001916
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001917 // Parse the original branch name to extract base name and starting number
1918 baseBranch, startNum := parseBranchNameAndNumber(originalBranch)
1919
Philip Zeyliger113e2052025-05-09 21:59:40 +00001920 // Try up to 10 times with different branch names if the branch is checked out on the remote
1921 var out []byte
1922 var err error
1923 for retries := range 10 {
1924 if retries > 0 {
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001925 // Increment from the starting number (foo1->foo2, foo2->foo3, etc.)
1926 branch = fmt.Sprintf("%s%d", baseBranch, startNum+retries)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001927 }
1928
Philip Zeyligerf2872992025-05-22 10:35:28 -07001929 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1930 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001931 out, err = cmd.CombinedOutput()
1932
1933 if err == nil {
1934 // Success! Break out of the retry loop
1935 break
1936 }
1937
1938 // Check if this is the "refusing to update checked out branch" error
1939 if !strings.Contains(string(out), "refusing to update checked out branch") {
1940 // This is a different error, so don't retry
1941 break
1942 }
1943
1944 // If we're on the last retry, we'll report the error
1945 if retries == 9 {
1946 break
1947 }
1948 }
1949
1950 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001951 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001952 } else {
1953 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001954 // Update the agent's branch name if we ended up using a different one
1955 if branch != originalBranch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001956 ags.branchName = branch
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001957 // Notify user why the branch name was changed
1958 msgs = append(msgs, AgentMessage{
1959 Type: AutoMessageType,
1960 Timestamp: time.Now(),
1961 Content: fmt.Sprintf("Branch renamed from %s to %s because the original branch is currently checked out on the remote.", originalBranch, branch),
1962 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00001963 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001964 }
1965 }
1966
1967 // If we found new commits, create a message
1968 if len(commits) > 0 {
1969 msg := AgentMessage{
1970 Type: CommitMessageType,
1971 Timestamp: time.Now(),
1972 Commits: commits,
1973 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001974 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001975 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001976 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001977}
1978
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001979func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001980 return strings.Map(func(r rune) rune {
1981 // lowercase
1982 if r >= 'A' && r <= 'Z' {
1983 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001984 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001985 // replace spaces with dashes
1986 if r == ' ' {
1987 return '-'
1988 }
1989 // allow alphanumerics and dashes
1990 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1991 return r
1992 }
1993 return -1
1994 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001995}
1996
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001997// parseBranchNameAndNumber extracts the base branch name and starting number.
1998// For "sketch/foo1" returns ("sketch/foo", 1)
1999// For "sketch/foo" returns ("sketch/foo", 0)
2000func parseBranchNameAndNumber(branchName string) (baseBranch string, startNum int) {
2001 re := regexp.MustCompile(`^(.+?)(\d+)$`)
2002 matches := re.FindStringSubmatch(branchName)
2003
2004 if len(matches) != 3 {
2005 // No trailing digits found
2006 return branchName, 0
2007 }
2008
2009 num, err := strconv.Atoi(matches[2])
2010 if err != nil {
2011 // If parsing fails, treat as no number
2012 return branchName, 0
2013 }
2014
2015 return matches[1], num
2016}
2017
Earl Lee2e463fb2025-04-17 11:22:22 -07002018// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2019// and returns an array of GitCommit structs.
2020func parseGitLog(output string) []GitCommit {
2021 var commits []GitCommit
2022
2023 // No output means no commits
2024 if len(output) == 0 {
2025 return commits
2026 }
2027
2028 // Split by NULL byte
2029 parts := strings.Split(output, "\x00")
2030
2031 // Process in triplets (hash, subject, body)
2032 for i := 0; i < len(parts); i++ {
2033 // Skip empty parts
2034 if parts[i] == "" {
2035 continue
2036 }
2037
2038 // This should be a hash
2039 hash := strings.TrimSpace(parts[i])
2040
2041 // Make sure we have at least a subject part available
2042 if i+1 >= len(parts) {
2043 break // No more parts available
2044 }
2045
2046 // Get the subject
2047 subject := strings.TrimSpace(parts[i+1])
2048
2049 // Get the body if available
2050 body := ""
2051 if i+2 < len(parts) {
2052 body = strings.TrimSpace(parts[i+2])
2053 }
2054
2055 // Skip to the next triplet
2056 i += 2
2057
2058 commits = append(commits, GitCommit{
2059 Hash: hash,
2060 Subject: subject,
2061 Body: body,
2062 })
2063 }
2064
2065 return commits
2066}
2067
2068func repoRoot(ctx context.Context, dir string) (string, error) {
2069 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2070 stderr := new(strings.Builder)
2071 cmd.Stderr = stderr
2072 cmd.Dir = dir
2073 out, err := cmd.Output()
2074 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002075 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002076 }
2077 return strings.TrimSpace(string(out)), nil
2078}
2079
2080func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2081 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2082 stderr := new(strings.Builder)
2083 cmd.Stderr = stderr
2084 cmd.Dir = dir
2085 out, err := cmd.Output()
2086 if err != nil {
2087 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2088 }
2089 // TODO: validate that out is valid hex
2090 return strings.TrimSpace(string(out)), nil
2091}
2092
2093// isValidGitSHA validates if a string looks like a valid git SHA hash.
2094// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2095func isValidGitSHA(sha string) bool {
2096 // Git SHA must be a hexadecimal string with at least 4 characters
2097 if len(sha) < 4 || len(sha) > 40 {
2098 return false
2099 }
2100
2101 // Check if the string only contains hexadecimal characters
2102 for _, char := range sha {
2103 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2104 return false
2105 }
2106 }
2107
2108 return true
2109}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002110
2111// getGitOrigin returns the URL of the git remote 'origin' if it exists
2112func getGitOrigin(ctx context.Context, dir string) string {
2113 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2114 cmd.Dir = dir
2115 stderr := new(strings.Builder)
2116 cmd.Stderr = stderr
2117 out, err := cmd.Output()
2118 if err != nil {
2119 return ""
2120 }
2121 return strings.TrimSpace(string(out))
2122}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002123
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002124// systemPromptData contains the data used to render the system prompt template
2125type systemPromptData struct {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002126 ClientGOOS string
2127 ClientGOARCH string
2128 WorkingDir string
2129 RepoRoot string
2130 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002131 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002132}
2133
2134// renderSystemPrompt renders the system prompt template.
2135func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002136 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002137 ClientGOOS: a.config.ClientGOOS,
2138 ClientGOARCH: a.config.ClientGOARCH,
2139 WorkingDir: a.workingDir,
2140 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002141 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002142 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002143 }
2144
2145 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2146 if err != nil {
2147 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2148 }
2149 buf := new(strings.Builder)
2150 err = tmpl.Execute(buf, data)
2151 if err != nil {
2152 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2153 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002154 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002155 return buf.String()
2156}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002157
2158// StateTransitionIterator provides an iterator over state transitions.
2159type StateTransitionIterator interface {
2160 // Next blocks until a new state transition is available or context is done.
2161 // Returns nil if the context is cancelled.
2162 Next() *StateTransition
2163 // Close removes the listener and cleans up resources.
2164 Close()
2165}
2166
2167// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2168type StateTransitionIteratorImpl struct {
2169 agent *Agent
2170 ctx context.Context
2171 ch chan StateTransition
2172 unsubscribe func()
2173}
2174
2175// Next blocks until a new state transition is available or the context is cancelled.
2176func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2177 select {
2178 case <-s.ctx.Done():
2179 return nil
2180 case transition, ok := <-s.ch:
2181 if !ok {
2182 return nil
2183 }
2184 transitionCopy := transition
2185 return &transitionCopy
2186 }
2187}
2188
2189// Close removes the listener and cleans up resources.
2190func (s *StateTransitionIteratorImpl) Close() {
2191 if s.unsubscribe != nil {
2192 s.unsubscribe()
2193 s.unsubscribe = nil
2194 }
2195}
2196
2197// NewStateTransitionIterator returns an iterator that receives state transitions.
2198func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2199 a.mu.Lock()
2200 defer a.mu.Unlock()
2201
2202 // Create channel to receive state transitions
2203 ch := make(chan StateTransition, 10)
2204
2205 // Add a listener to the state machine
2206 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2207
2208 return &StateTransitionIteratorImpl{
2209 agent: a,
2210 ctx: ctx,
2211 ch: ch,
2212 unsubscribe: unsubscribe,
2213 }
2214}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002215
2216// setupGitHooks creates or updates git hooks in the specified working directory.
2217func setupGitHooks(workingDir string) error {
2218 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2219
2220 _, err := os.Stat(hooksDir)
2221 if os.IsNotExist(err) {
2222 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2223 }
2224 if err != nil {
2225 return fmt.Errorf("error checking git hooks directory: %w", err)
2226 }
2227
2228 // Define the post-commit hook content
2229 postCommitHook := `#!/bin/bash
2230echo "<post_commit_hook>"
2231echo "Please review this commit message and fix it if it is incorrect."
2232echo "This hook only echos the commit message; it does not modify it."
2233echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2234echo "<last_commit_message>"
2235git log -1 --pretty=%B
2236echo "</last_commit_message>"
2237echo "</post_commit_hook>"
2238`
2239
2240 // Define the prepare-commit-msg hook content
2241 prepareCommitMsgHook := `#!/bin/bash
2242# Add Co-Authored-By and Change-ID trailers to commit messages
2243# Check if these trailers already exist before adding them
2244
2245commit_file="$1"
2246COMMIT_SOURCE="$2"
2247
2248# Skip for merges, squashes, or when using a commit template
2249if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2250 [ "$COMMIT_SOURCE" = "squash" ]; then
2251 exit 0
2252fi
2253
2254commit_msg=$(cat "$commit_file")
2255
2256needs_co_author=true
2257needs_change_id=true
2258
2259# Check if commit message already has Co-Authored-By trailer
2260if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2261 needs_co_author=false
2262fi
2263
2264# Check if commit message already has Change-ID trailer
2265if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2266 needs_change_id=false
2267fi
2268
2269# Only modify if at least one trailer needs to be added
2270if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002271 # Ensure there's a proper blank line before trailers
2272 if [ -s "$commit_file" ]; then
2273 # Check if file ends with newline by reading last character
2274 last_char=$(tail -c 1 "$commit_file")
2275
2276 if [ "$last_char" != "" ]; then
2277 # File doesn't end with newline - add two newlines (complete line + blank line)
2278 echo "" >> "$commit_file"
2279 echo "" >> "$commit_file"
2280 else
2281 # File ends with newline - check if we already have a blank line
2282 last_line=$(tail -1 "$commit_file")
2283 if [ -n "$last_line" ]; then
2284 # Last line has content - add one newline for blank line
2285 echo "" >> "$commit_file"
2286 fi
2287 # If last line is empty, we already have a blank line - don't add anything
2288 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002289 fi
2290
2291 # Add trailers if needed
2292 if [ "$needs_co_author" = true ]; then
2293 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2294 fi
2295
2296 if [ "$needs_change_id" = true ]; then
2297 change_id=$(openssl rand -hex 8)
2298 echo "Change-ID: s${change_id}k" >> "$commit_file"
2299 fi
2300fi
2301`
2302
2303 // Update or create the post-commit hook
2304 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2305 if err != nil {
2306 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2307 }
2308
2309 // Update or create the prepare-commit-msg hook
2310 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2311 if err != nil {
2312 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2313 }
2314
2315 return nil
2316}
2317
2318// updateOrCreateHook creates a new hook file or updates an existing one
2319// by appending the new content if it doesn't already contain it.
2320func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2321 // Check if the hook already exists
2322 buf, err := os.ReadFile(hookPath)
2323 if os.IsNotExist(err) {
2324 // Hook doesn't exist, create it
2325 err = os.WriteFile(hookPath, []byte(content), 0o755)
2326 if err != nil {
2327 return fmt.Errorf("failed to create hook: %w", err)
2328 }
2329 return nil
2330 }
2331 if err != nil {
2332 return fmt.Errorf("error reading existing hook: %w", err)
2333 }
2334
2335 // Hook exists, check if our content is already in it by looking for a distinctive line
2336 code := string(buf)
2337 if strings.Contains(code, distinctiveLine) {
2338 // Already contains our content, nothing to do
2339 return nil
2340 }
2341
2342 // Append our content to the existing hook
2343 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2344 if err != nil {
2345 return fmt.Errorf("failed to open hook for appending: %w", err)
2346 }
2347 defer f.Close()
2348
2349 // Ensure there's a newline at the end of the existing content if needed
2350 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2351 _, err = f.WriteString("\n")
2352 if err != nil {
2353 return fmt.Errorf("failed to add newline to hook: %w", err)
2354 }
2355 }
2356
2357 // Add a separator before our content
2358 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2359 if err != nil {
2360 return fmt.Errorf("failed to append to hook: %w", err)
2361 }
2362
2363 return nil
2364}
Sean McCullough138ec242025-06-02 22:42:06 +00002365
2366// GetPortMonitor returns the port monitor instance for accessing port events
2367func (a *Agent) GetPortMonitor() *PortMonitor {
2368 return a.portMonitor
2369}