blob: d8697c41a2477588ac0ea1568ac8be5442df0dc0 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package loop
2
3import (
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07004 "cmp"
Earl Lee2e463fb2025-04-17 11:22:22 -07005 "context"
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07006 _ "embed"
Earl Lee2e463fb2025-04-17 11:22:22 -07007 "encoding/json"
8 "fmt"
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +00009 "io"
Earl Lee2e463fb2025-04-17 11:22:22 -070010 "log/slog"
11 "net/http"
12 "os"
13 "os/exec"
Pokey Rule7a113622025-05-12 10:58:45 +010014 "path/filepath"
Earl Lee2e463fb2025-04-17 11:22:22 -070015 "runtime/debug"
16 "slices"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070017 "strconv"
Earl Lee2e463fb2025-04-17 11:22:22 -070018 "strings"
19 "sync"
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +000020 "text/template"
Earl Lee2e463fb2025-04-17 11:22:22 -070021 "time"
22
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000023 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070024 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000025 "sketch.dev/claudetool/bashkit"
Autoformatter4962f152025-05-06 17:24:20 +000026 "sketch.dev/claudetool/browse"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000027 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydera997be62025-05-07 22:52:46 +000028 "sketch.dev/claudetool/onstart"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070029 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070030 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070031 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070032)
33
34const (
35 userCancelMessage = "user requested agent to stop handling responses"
36)
37
Philip Zeyligerb5739402025-06-02 07:04:34 -070038// EndFeedback represents user feedback when ending a session
39type EndFeedback struct {
40 Happy bool `json:"happy"`
41 Comment string `json:"comment"`
42}
43
Philip Zeyligerb7c58752025-05-01 10:10:17 -070044type MessageIterator interface {
45 // Next blocks until the next message is available. It may
46 // return nil if the underlying iterator context is done.
47 Next() *AgentMessage
48 Close()
49}
50
Earl Lee2e463fb2025-04-17 11:22:22 -070051type CodingAgent interface {
52 // Init initializes an agent inside a docker container.
53 Init(AgentInit) error
54
55 // Ready returns a channel closed after Init successfully called.
56 Ready() <-chan struct{}
57
58 // URL reports the HTTP URL of this agent.
59 URL() string
60
61 // UserMessage enqueues a message to the agent and returns immediately.
62 UserMessage(ctx context.Context, msg string)
63
Philip Zeyligerb7c58752025-05-01 10:10:17 -070064 // Returns an iterator that finishes when the context is done and
65 // starts with the given message index.
66 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070067
Philip Zeyligereab12de2025-05-14 02:35:53 +000068 // Returns an iterator that notifies of state transitions until the context is done.
69 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
70
Earl Lee2e463fb2025-04-17 11:22:22 -070071 // Loop begins the agent loop returns only when ctx is cancelled.
72 Loop(ctx context.Context)
73
Sean McCulloughedc88dc2025-04-30 02:55:01 +000074 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070075
76 CancelToolUse(toolUseID string, cause error) error
77
78 // Returns a subset of the agent's message history.
79 Messages(start int, end int) []AgentMessage
80
81 // Returns the current number of messages in the history
82 MessageCount() int
83
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070084 TotalUsage() conversation.CumulativeUsage
85 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070086
Earl Lee2e463fb2025-04-17 11:22:22 -070087 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000088 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070089
90 // Diff returns a unified diff of changes made since the agent was instantiated.
91 // If commit is non-nil, it shows the diff for just that specific commit.
92 Diff(commit *string) (string, error)
93
Philip Zeyliger49edc922025-05-14 09:45:45 -070094 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
95 // starts out as the commit where sketch started, but a user can move it if need
96 // be, for example in the case of a rebase. It is stored as a git tag.
97 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070098
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000099 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
100 // (Typically, this is "sketch-base")
101 SketchGitBaseRef() string
102
Earl Lee2e463fb2025-04-17 11:22:22 -0700103 // Title returns the current title of the conversation.
104 Title() string
105
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000106 // BranchName returns the git branch name for the conversation.
107 BranchName() string
108
Earl Lee2e463fb2025-04-17 11:22:22 -0700109 // OS returns the operating system of the client.
110 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000111
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000112 // SessionID returns the unique session identifier.
113 SessionID() string
114
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000115 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700116 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000117
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000118 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
119 OutstandingLLMCallCount() int
120
121 // OutstandingToolCalls returns the names of outstanding tool calls.
122 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000123 OutsideOS() string
124 OutsideHostname() string
125 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000126 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000127 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
128 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700129
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700130 // IsInContainer returns true if the agent is running in a container
131 IsInContainer() bool
132 // FirstMessageIndex returns the index of the first message in the current conversation
133 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700134
135 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700136 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
137 CurrentTodoContent() string
Philip Zeyligerb5739402025-06-02 07:04:34 -0700138 // GetEndFeedback returns the end session feedback
139 GetEndFeedback() *EndFeedback
140 // SetEndFeedback sets the end session feedback
141 SetEndFeedback(feedback *EndFeedback)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700142
143 // CompactConversation compacts the current conversation by generating a summary
144 // and restarting the conversation with that summary as the initial context
145 CompactConversation(ctx context.Context) error
Earl Lee2e463fb2025-04-17 11:22:22 -0700146}
147
148type CodingAgentMessageType string
149
150const (
151 UserMessageType CodingAgentMessageType = "user"
152 AgentMessageType CodingAgentMessageType = "agent"
153 ErrorMessageType CodingAgentMessageType = "error"
154 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
155 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700156 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
157 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
158 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700159
160 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
161)
162
163type AgentMessage struct {
164 Type CodingAgentMessageType `json:"type"`
165 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
166 EndOfTurn bool `json:"end_of_turn"`
167
168 Content string `json:"content"`
169 ToolName string `json:"tool_name,omitempty"`
170 ToolInput string `json:"input,omitempty"`
171 ToolResult string `json:"tool_result,omitempty"`
172 ToolError bool `json:"tool_error,omitempty"`
173 ToolCallId string `json:"tool_call_id,omitempty"`
174
175 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
176 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
177
Sean McCulloughd9f13372025-04-21 15:08:49 -0700178 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
179 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
180
Earl Lee2e463fb2025-04-17 11:22:22 -0700181 // Commits is a list of git commits for a commit message
182 Commits []*GitCommit `json:"commits,omitempty"`
183
184 Timestamp time.Time `json:"timestamp"`
185 ConversationID string `json:"conversation_id"`
186 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700187 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700188
189 // Message timing information
190 StartTime *time.Time `json:"start_time,omitempty"`
191 EndTime *time.Time `json:"end_time,omitempty"`
192 Elapsed *time.Duration `json:"elapsed,omitempty"`
193
194 // Turn duration - the time taken for a complete agent turn
195 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
196
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000197 // HideOutput indicates that this message should not be rendered in the UI.
198 // This is useful for subconversations that generate output that shouldn't be shown to the user.
199 HideOutput bool `json:"hide_output,omitempty"`
200
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700201 // TodoContent contains the agent's todo file content when it has changed
202 TodoContent *string `json:"todo_content,omitempty"`
203
Earl Lee2e463fb2025-04-17 11:22:22 -0700204 Idx int `json:"idx"`
205}
206
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000207// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700208func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700209 if convo == nil {
210 m.ConversationID = ""
211 m.ParentConversationID = nil
212 return
213 }
214 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000215 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700216 if convo.Parent != nil {
217 m.ParentConversationID = &convo.Parent.ID
218 }
219}
220
Earl Lee2e463fb2025-04-17 11:22:22 -0700221// GitCommit represents a single git commit for a commit message
222type GitCommit struct {
223 Hash string `json:"hash"` // Full commit hash
224 Subject string `json:"subject"` // Commit subject line
225 Body string `json:"body"` // Full commit message body
226 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
227}
228
229// ToolCall represents a single tool call within an agent message
230type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700231 Name string `json:"name"`
232 Input string `json:"input"`
233 ToolCallId string `json:"tool_call_id"`
234 ResultMessage *AgentMessage `json:"result_message,omitempty"`
235 Args string `json:"args,omitempty"`
236 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700237}
238
239func (a *AgentMessage) Attr() slog.Attr {
240 var attrs []any = []any{
241 slog.String("type", string(a.Type)),
242 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700243 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700244 if a.EndOfTurn {
245 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
246 }
247 if a.Content != "" {
248 attrs = append(attrs, slog.String("content", a.Content))
249 }
250 if a.ToolName != "" {
251 attrs = append(attrs, slog.String("tool_name", a.ToolName))
252 }
253 if a.ToolInput != "" {
254 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
255 }
256 if a.Elapsed != nil {
257 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
258 }
259 if a.TurnDuration != nil {
260 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
261 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700262 if len(a.ToolResult) > 0 {
263 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700264 }
265 if a.ToolError {
266 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
267 }
268 if len(a.ToolCalls) > 0 {
269 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
270 for i, tc := range a.ToolCalls {
271 toolCallAttrs = append(toolCallAttrs, slog.Group(
272 fmt.Sprintf("tool_call_%d", i),
273 slog.String("name", tc.Name),
274 slog.String("input", tc.Input),
275 ))
276 }
277 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
278 }
279 if a.ConversationID != "" {
280 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
281 }
282 if a.ParentConversationID != nil {
283 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
284 }
285 if a.Usage != nil && !a.Usage.IsZero() {
286 attrs = append(attrs, a.Usage.Attr())
287 }
288 // TODO: timestamp, convo ids, idx?
289 return slog.Group("agent_message", attrs...)
290}
291
292func errorMessage(err error) AgentMessage {
293 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
294 if os.Getenv(("DEBUG")) == "1" {
295 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
296 }
297
298 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
299}
300
301func budgetMessage(err error) AgentMessage {
302 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
303}
304
305// ConvoInterface defines the interface for conversation interactions
306type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700307 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700308 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700309 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700310 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700311 SendMessage(message llm.Message) (*llm.Response, error)
312 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700313 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000314 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700315 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700316 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700317 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700318}
319
Philip Zeyligerf2872992025-05-22 10:35:28 -0700320// AgentGitState holds the state necessary for pushing to a remote git repo
321// when HEAD changes. If gitRemoteAddr is set, then we push to sketch/
322// any time we notice we need to.
323type AgentGitState struct {
324 mu sync.Mutex // protects following
325 lastHEAD string // hash of the last HEAD that was pushed to the host
326 gitRemoteAddr string // HTTP URL of the host git repo
327 seenCommits map[string]bool // Track git commits we've already seen (by hash)
328 branchName string
329}
330
331func (ags *AgentGitState) SetBranchName(branchName string) {
332 ags.mu.Lock()
333 defer ags.mu.Unlock()
334 ags.branchName = branchName
335}
336
337func (ags *AgentGitState) BranchName() string {
338 ags.mu.Lock()
339 defer ags.mu.Unlock()
340 return ags.branchName
341}
342
Earl Lee2e463fb2025-04-17 11:22:22 -0700343type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700344 convo ConvoInterface
345 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700346 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700347 workingDir string
348 repoRoot string // workingDir may be a subdir of repoRoot
349 url string
350 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000351 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700352 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000353 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700354 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700355 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700356 title string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000357 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700358 // State machine to track agent state
359 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000360 // Outside information
361 outsideHostname string
362 outsideOS string
363 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000364 // URL of the git remote 'origin' if it exists
365 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700366
367 // Time when the current turn started (reset at the beginning of InnerLoop)
368 startOfTurn time.Time
369
370 // Inbox - for messages from the user to the agent.
371 // sent on by UserMessage
372 // . e.g. when user types into the chat textarea
373 // read from by GatherMessages
374 inbox chan string
375
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000376 // protects cancelTurn
377 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700378 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000379 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700380
381 // protects following
382 mu sync.Mutex
383
384 // Stores all messages for this agent
385 history []AgentMessage
386
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700387 // Iterators add themselves here when they're ready to be notified of new messages.
388 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700389
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000390 // Track outstanding LLM call IDs
391 outstandingLLMCalls map[string]struct{}
392
393 // Track outstanding tool calls by ID with their names
394 outstandingToolCalls map[string]string
Sean McCullough364f7412025-06-02 00:55:44 +0000395
396 // Port monitoring
397 portMonitor *PortMonitor
Philip Zeyligerb5739402025-06-02 07:04:34 -0700398
399 // End session feedback
400 endFeedback *EndFeedback
Earl Lee2e463fb2025-04-17 11:22:22 -0700401}
402
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700403// NewIterator implements CodingAgent.
404func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
405 a.mu.Lock()
406 defer a.mu.Unlock()
407
408 return &MessageIteratorImpl{
409 agent: a,
410 ctx: ctx,
411 nextMessageIdx: nextMessageIdx,
412 ch: make(chan *AgentMessage, 100),
413 }
414}
415
416type MessageIteratorImpl struct {
417 agent *Agent
418 ctx context.Context
419 nextMessageIdx int
420 ch chan *AgentMessage
421 subscribed bool
422}
423
424func (m *MessageIteratorImpl) Close() {
425 m.agent.mu.Lock()
426 defer m.agent.mu.Unlock()
427 // Delete ourselves from the subscribers list
428 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
429 return x == m.ch
430 })
431 close(m.ch)
432}
433
434func (m *MessageIteratorImpl) Next() *AgentMessage {
435 // We avoid subscription at creation to let ourselves catch up to "current state"
436 // before subscribing.
437 if !m.subscribed {
438 m.agent.mu.Lock()
439 if m.nextMessageIdx < len(m.agent.history) {
440 msg := &m.agent.history[m.nextMessageIdx]
441 m.nextMessageIdx++
442 m.agent.mu.Unlock()
443 return msg
444 }
445 // The next message doesn't exist yet, so let's subscribe
446 m.agent.subscribers = append(m.agent.subscribers, m.ch)
447 m.subscribed = true
448 m.agent.mu.Unlock()
449 }
450
451 for {
452 select {
453 case <-m.ctx.Done():
454 m.agent.mu.Lock()
455 // Delete ourselves from the subscribers list
456 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
457 return x == m.ch
458 })
459 m.subscribed = false
460 m.agent.mu.Unlock()
461 return nil
462 case msg, ok := <-m.ch:
463 if !ok {
464 // Close may have been called
465 return nil
466 }
467 if msg.Idx == m.nextMessageIdx {
468 m.nextMessageIdx++
469 return msg
470 }
471 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
472 panic("out of order message")
473 }
474 }
475}
476
Sean McCulloughd9d45812025-04-30 16:53:41 -0700477// Assert that Agent satisfies the CodingAgent interface.
478var _ CodingAgent = &Agent{}
479
480// StateName implements CodingAgent.
481func (a *Agent) CurrentStateName() string {
482 if a.stateMachine == nil {
483 return ""
484 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000485 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700486}
487
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700488// CurrentTodoContent returns the current todo list data as JSON.
489// It returns an empty string if no todos exist.
490func (a *Agent) CurrentTodoContent() string {
491 todoPath := claudetool.TodoFilePath(a.config.SessionID)
492 content, err := os.ReadFile(todoPath)
493 if err != nil {
494 return ""
495 }
496 return string(content)
497}
498
Philip Zeyligerb5739402025-06-02 07:04:34 -0700499// SetEndFeedback sets the end session feedback
500func (a *Agent) SetEndFeedback(feedback *EndFeedback) {
501 a.mu.Lock()
502 defer a.mu.Unlock()
503 a.endFeedback = feedback
504}
505
506// GetEndFeedback gets the end session feedback
507func (a *Agent) GetEndFeedback() *EndFeedback {
508 a.mu.Lock()
509 defer a.mu.Unlock()
510 return a.endFeedback
511}
512
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700513// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
514func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
515 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.
516
517IMPORTANT: 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.
518
519Please create a detailed summary that includes:
520
5211. **User's Request**: What did the user originally ask me to do? What was their goal?
522
5232. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
524
5253. **Key Technical Decisions**: What important technical choices were made during our work and why?
526
5274. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
528
5295. **Next Steps**: What still needs to be done to complete the user's request?
530
5316. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
532
533Focus 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.
534
535Reply with ONLY the summary content - no meta-commentary about creating the summary.`
536
537 userMessage := llm.UserStringMessage(msg)
538 // Use a subconversation with history to get the summary
539 // TODO: We don't have any tools here, so we should have enough tokens
540 // to capture a summary, but we may need to modify the history (e.g., remove
541 // TODO data) to save on some tokens.
542 convo := a.convo.SubConvoWithHistory()
543
544 // Modify the system prompt to provide context about the original task
545 originalSystemPrompt := convo.SystemPrompt
546 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.
547
548Your 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.
549
550Original context: You are working in a coding environment with full access to development tools.`)
551
552 resp, err := convo.SendMessage(userMessage)
553 if err != nil {
554 a.pushToOutbox(ctx, errorMessage(err))
555 return "", err
556 }
557 textContent := collectTextContent(resp)
558
559 // Restore original system prompt (though this subconvo will be discarded)
560 convo.SystemPrompt = originalSystemPrompt
561
562 return textContent, nil
563}
564
565// CompactConversation compacts the current conversation by generating a summary
566// and restarting the conversation with that summary as the initial context
567func (a *Agent) CompactConversation(ctx context.Context) error {
568 summary, err := a.generateConversationSummary(ctx)
569 if err != nil {
570 return fmt.Errorf("failed to generate conversation summary: %w", err)
571 }
572
573 a.mu.Lock()
574
575 // Get usage information before resetting conversation
576 lastUsage := a.convo.LastUsage()
577 contextWindow := a.config.Service.TokenContextWindow()
578 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
579
580 // Reset conversation state but keep all other state (git, working dir, etc.)
581 a.firstMessageIndex = len(a.history)
582 a.convo = a.initConvo()
583
584 a.mu.Unlock()
585
586 // Create informative compaction message with token details
587 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
588 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
589 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
590
591 a.pushToOutbox(ctx, AgentMessage{
592 Type: CompactMessageType,
593 Content: compactionMsg,
594 })
595
596 a.pushToOutbox(ctx, AgentMessage{
597 Type: UserMessageType,
598 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),
599 })
600 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)
601
602 return nil
603}
604
Earl Lee2e463fb2025-04-17 11:22:22 -0700605func (a *Agent) URL() string { return a.url }
606
607// Title returns the current title of the conversation.
608// If no title has been set, returns an empty string.
609func (a *Agent) Title() string {
610 a.mu.Lock()
611 defer a.mu.Unlock()
612 return a.title
613}
614
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000615// BranchName returns the git branch name for the conversation.
616func (a *Agent) BranchName() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700617 return a.gitState.BranchName()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000618}
619
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000620// OutstandingLLMCallCount returns the number of outstanding LLM calls.
621func (a *Agent) OutstandingLLMCallCount() int {
622 a.mu.Lock()
623 defer a.mu.Unlock()
624 return len(a.outstandingLLMCalls)
625}
626
627// OutstandingToolCalls returns the names of outstanding tool calls.
628func (a *Agent) OutstandingToolCalls() []string {
629 a.mu.Lock()
630 defer a.mu.Unlock()
631
632 tools := make([]string, 0, len(a.outstandingToolCalls))
633 for _, toolName := range a.outstandingToolCalls {
634 tools = append(tools, toolName)
635 }
636 return tools
637}
638
Earl Lee2e463fb2025-04-17 11:22:22 -0700639// OS returns the operating system of the client.
640func (a *Agent) OS() string {
641 return a.config.ClientGOOS
642}
643
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000644func (a *Agent) SessionID() string {
645 return a.config.SessionID
646}
647
Philip Zeyliger18532b22025-04-23 21:11:46 +0000648// OutsideOS returns the operating system of the outside system.
649func (a *Agent) OutsideOS() string {
650 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000651}
652
Philip Zeyliger18532b22025-04-23 21:11:46 +0000653// OutsideHostname returns the hostname of the outside system.
654func (a *Agent) OutsideHostname() string {
655 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000656}
657
Philip Zeyliger18532b22025-04-23 21:11:46 +0000658// OutsideWorkingDir returns the working directory on the outside system.
659func (a *Agent) OutsideWorkingDir() string {
660 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000661}
662
663// GitOrigin returns the URL of the git remote 'origin' if it exists.
664func (a *Agent) GitOrigin() string {
665 return a.gitOrigin
666}
667
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000668func (a *Agent) OpenBrowser(url string) {
669 if !a.IsInContainer() {
670 browser.Open(url)
671 return
672 }
673 // We're in Docker, need to send a request to the Git server
674 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700675 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000676 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700677 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000678 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700679 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000680 return
681 }
682 defer resp.Body.Close()
683 if resp.StatusCode == http.StatusOK {
684 return
685 }
686 body, _ := io.ReadAll(resp.Body)
687 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
688}
689
Sean McCullough96b60dd2025-04-30 09:49:10 -0700690// CurrentState returns the current state of the agent's state machine.
691func (a *Agent) CurrentState() State {
692 return a.stateMachine.CurrentState()
693}
694
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700695func (a *Agent) IsInContainer() bool {
696 return a.config.InDocker
697}
698
699func (a *Agent) FirstMessageIndex() int {
700 a.mu.Lock()
701 defer a.mu.Unlock()
702 return a.firstMessageIndex
703}
704
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000705// SetTitle sets the title of the conversation.
706func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700707 a.mu.Lock()
708 defer a.mu.Unlock()
709 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000710}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700711
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000712// SetBranch sets the branch name of the conversation.
713func (a *Agent) SetBranch(branchName string) {
714 a.mu.Lock()
715 defer a.mu.Unlock()
Philip Zeyligerf2872992025-05-22 10:35:28 -0700716 a.gitState.SetBranchName(branchName)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000717 convo, ok := a.convo.(*conversation.Convo)
718 if ok {
719 convo.ExtraData["branch"] = branchName
720 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700721}
722
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000723// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700724func (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 +0000725 // Track the tool call
726 a.mu.Lock()
727 a.outstandingToolCalls[id] = toolName
728 a.mu.Unlock()
729}
730
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700731// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
732// If there's only one element in the array and it's a text type, it returns that text directly.
733// It also processes nested ToolResult arrays recursively.
734func contentToString(contents []llm.Content) string {
735 if len(contents) == 0 {
736 return ""
737 }
738
739 // If there's only one element and it's a text type, return it directly
740 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
741 return contents[0].Text
742 }
743
744 // Otherwise, concatenate all text content
745 var result strings.Builder
746 for _, content := range contents {
747 if content.Type == llm.ContentTypeText {
748 result.WriteString(content.Text)
749 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
750 // Recursively process nested tool results
751 result.WriteString(contentToString(content.ToolResult))
752 }
753 }
754
755 return result.String()
756}
757
Earl Lee2e463fb2025-04-17 11:22:22 -0700758// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700759func (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 +0000760 // Remove the tool call from outstanding calls
761 a.mu.Lock()
762 delete(a.outstandingToolCalls, toolID)
763 a.mu.Unlock()
764
Earl Lee2e463fb2025-04-17 11:22:22 -0700765 m := AgentMessage{
766 Type: ToolUseMessageType,
767 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700768 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700769 ToolError: content.ToolError,
770 ToolName: toolName,
771 ToolInput: string(toolInput),
772 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700773 StartTime: content.ToolUseStartTime,
774 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700775 }
776
777 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700778 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
779 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700780 m.Elapsed = &elapsed
781 }
782
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700783 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700784 a.pushToOutbox(ctx, m)
785}
786
787// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700788func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000789 a.mu.Lock()
790 defer a.mu.Unlock()
791 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700792 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
793}
794
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700795// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700796// that need to be displayed (as well as tool calls that we send along when
797// they're done). (It would be reasonable to also mention tool calls when they're
798// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700799func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000800 // Remove the LLM call from outstanding calls
801 a.mu.Lock()
802 delete(a.outstandingLLMCalls, id)
803 a.mu.Unlock()
804
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700805 if resp == nil {
806 // LLM API call failed
807 m := AgentMessage{
808 Type: ErrorMessageType,
809 Content: "API call failed, type 'continue' to try again",
810 }
811 m.SetConvo(convo)
812 a.pushToOutbox(ctx, m)
813 return
814 }
815
Earl Lee2e463fb2025-04-17 11:22:22 -0700816 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700817 if convo.Parent == nil { // subconvos never end the turn
818 switch resp.StopReason {
819 case llm.StopReasonToolUse:
820 // Check whether any of the tool calls are for tools that should end the turn
821 ToolSearch:
822 for _, part := range resp.Content {
823 if part.Type != llm.ContentTypeToolUse {
824 continue
825 }
Sean McCullough021557a2025-05-05 23:20:53 +0000826 // Find the tool by name
827 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700828 if tool.Name == part.ToolName {
829 endOfTurn = tool.EndsTurn
830 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000831 }
832 }
Sean McCullough021557a2025-05-05 23:20:53 +0000833 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700834 default:
835 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000836 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700837 }
838 m := AgentMessage{
839 Type: AgentMessageType,
840 Content: collectTextContent(resp),
841 EndOfTurn: endOfTurn,
842 Usage: &resp.Usage,
843 StartTime: resp.StartTime,
844 EndTime: resp.EndTime,
845 }
846
847 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700848 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700849 var toolCalls []ToolCall
850 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700851 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700852 toolCalls = append(toolCalls, ToolCall{
853 Name: part.ToolName,
854 Input: string(part.ToolInput),
855 ToolCallId: part.ID,
856 })
857 }
858 }
859 m.ToolCalls = toolCalls
860 }
861
862 // Calculate the elapsed time if both start and end times are set
863 if resp.StartTime != nil && resp.EndTime != nil {
864 elapsed := resp.EndTime.Sub(*resp.StartTime)
865 m.Elapsed = &elapsed
866 }
867
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700868 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700869 a.pushToOutbox(ctx, m)
870}
871
872// WorkingDir implements CodingAgent.
873func (a *Agent) WorkingDir() string {
874 return a.workingDir
875}
876
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000877// RepoRoot returns the git repository root directory.
878func (a *Agent) RepoRoot() string {
879 return a.repoRoot
880}
881
Earl Lee2e463fb2025-04-17 11:22:22 -0700882// MessageCount implements CodingAgent.
883func (a *Agent) MessageCount() int {
884 a.mu.Lock()
885 defer a.mu.Unlock()
886 return len(a.history)
887}
888
889// Messages implements CodingAgent.
890func (a *Agent) Messages(start int, end int) []AgentMessage {
891 a.mu.Lock()
892 defer a.mu.Unlock()
893 return slices.Clone(a.history[start:end])
894}
895
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700896// ShouldCompact checks if the conversation should be compacted based on token usage
897func (a *Agent) ShouldCompact() bool {
898 // Get the threshold from environment variable, default to 0.94 (94%)
899 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
900 // and a little bit of buffer.)
901 thresholdRatio := 0.94
902 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
903 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
904 thresholdRatio = parsed
905 }
906 }
907
908 // Get the most recent usage to check current context size
909 lastUsage := a.convo.LastUsage()
910
911 if lastUsage.InputTokens == 0 {
912 // No API calls made yet
913 return false
914 }
915
916 // Calculate the current context size from the last API call
917 // This includes all tokens that were part of the input context:
918 // - Input tokens (user messages, system prompt, conversation history)
919 // - Cache read tokens (cached parts of the context)
920 // - Cache creation tokens (new parts being cached)
921 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
922
923 // Get the service's token context window
924 service := a.config.Service
925 contextWindow := service.TokenContextWindow()
926
927 // Calculate threshold
928 threshold := uint64(float64(contextWindow) * thresholdRatio)
929
930 // Check if we've exceeded the threshold
931 return currentContextSize >= threshold
932}
933
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700934func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700935 return a.originalBudget
936}
937
938// AgentConfig contains configuration for creating a new Agent.
939type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +0000940 Context context.Context
941 Service llm.Service
942 Budget conversation.Budget
943 GitUsername string
944 GitEmail string
945 SessionID string
946 ClientGOOS string
947 ClientGOARCH string
948 InDocker bool
949 OneShot bool
950 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000951 // Outside information
952 OutsideHostname string
953 OutsideOS string
954 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700955
956 // Outtie's HTTP to, e.g., open a browser
957 OutsideHTTP string
958 // Outtie's Git server
959 GitRemoteAddr string
960 // Commit to checkout from Outtie
961 Commit string
Earl Lee2e463fb2025-04-17 11:22:22 -0700962}
963
964// NewAgent creates a new Agent.
965// It is not usable until Init() is called.
966func NewAgent(config AgentConfig) *Agent {
967 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -0700968 config: config,
969 ready: make(chan struct{}),
970 inbox: make(chan string, 100),
971 subscribers: make([]chan *AgentMessage, 0),
972 startedAt: time.Now(),
973 originalBudget: config.Budget,
974 gitState: AgentGitState{
975 seenCommits: make(map[string]bool),
976 gitRemoteAddr: config.GitRemoteAddr,
977 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000978 outsideHostname: config.OutsideHostname,
979 outsideOS: config.OutsideOS,
980 outsideWorkingDir: config.OutsideWorkingDir,
981 outstandingLLMCalls: make(map[string]struct{}),
982 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700983 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700984 workingDir: config.WorkingDir,
985 outsideHTTP: config.OutsideHTTP,
Sean McCullough364f7412025-06-02 00:55:44 +0000986 portMonitor: NewPortMonitor(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700987 }
988 return agent
989}
990
991type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700992 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -0700993
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700994 InDocker bool
995 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -0700996}
997
998func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700999 if a.convo != nil {
1000 return fmt.Errorf("Agent.Init: already initialized")
1001 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001002 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001003 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001004
Philip Zeyligerf2872992025-05-22 10:35:28 -07001005 // If a remote git addr was specified, we configure the remote
1006 if a.gitState.gitRemoteAddr != "" {
1007 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
1008 cmd := exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", a.gitState.gitRemoteAddr)
1009 cmd.Dir = a.workingDir
1010 if out, err := cmd.CombinedOutput(); err != nil {
1011 return fmt.Errorf("git remote add: %s: %v", out, err)
1012 }
1013 // sketch-host is a git repo hosted by "outtie sketch". When it notices a 'git fetch',
1014 // it runs "git fetch" underneath the covers to get its latest commits. By configuring
1015 // an additional remote.sketch-host.fetch, we make "origin/main" on innie sketch look like
1016 // origin/main on outtie sketch, which should make it easier to rebase.
1017 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.sketch-host.fetch",
1018 "+refs/heads/feature/*:refs/remotes/origin/feature/*")
1019 cmd.Dir = a.workingDir
1020 if out, err := cmd.CombinedOutput(); err != nil {
1021 return fmt.Errorf("git config --add: %s: %v", out, err)
1022 }
1023 }
1024
1025 // If a commit was specified, we fetch and reset to it.
1026 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001027 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1028
Earl Lee2e463fb2025-04-17 11:22:22 -07001029 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001030 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001031 if out, err := cmd.CombinedOutput(); err != nil {
1032 return fmt.Errorf("git stash: %s: %v", out, err)
1033 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001034 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001035 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001036 if out, err := cmd.CombinedOutput(); err != nil {
1037 return fmt.Errorf("git fetch: %s: %w", out, err)
1038 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001039 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1040 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001041 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1042 // Remove git hooks if they exist and retry
1043 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001044 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001045 if _, statErr := os.Stat(hookPath); statErr == nil {
1046 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1047 slog.String("error", err.Error()),
1048 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001049 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001050 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1051 }
1052
1053 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001054 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1055 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001056 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001057 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 +01001058 }
1059 } else {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001060 return fmt.Errorf("git checkout %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001061 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001062 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001063 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001064
1065 if ini.HostAddr != "" {
1066 a.url = "http://" + ini.HostAddr
1067 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001068
1069 if !ini.NoGit {
1070 repoRoot, err := repoRoot(ctx, a.workingDir)
1071 if err != nil {
1072 return fmt.Errorf("repoRoot: %w", err)
1073 }
1074 a.repoRoot = repoRoot
1075
Earl Lee2e463fb2025-04-17 11:22:22 -07001076 if err != nil {
1077 return fmt.Errorf("resolveRef: %w", err)
1078 }
Philip Zeyliger49edc922025-05-14 09:45:45 -07001079
Josh Bleecher Snyder90993a02025-05-28 18:15:15 -07001080 if err := setupGitHooks(a.repoRoot); err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001081 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1082 }
1083
Philip Zeyliger49edc922025-05-14 09:45:45 -07001084 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1085 cmd.Dir = repoRoot
1086 if out, err := cmd.CombinedOutput(); err != nil {
1087 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1088 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001089
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001090 slog.Info("running codebase analysis")
1091 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1092 if err != nil {
1093 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001094 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001095 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001096
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001097 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001098 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001099 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001100 }
1101 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001102
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001103 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -07001104 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001105 a.gitState.lastHEAD = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001106 a.convo = a.initConvo()
1107 close(a.ready)
1108 return nil
1109}
1110
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001111//go:embed agent_system_prompt.txt
1112var agentSystemPrompt string
1113
Earl Lee2e463fb2025-04-17 11:22:22 -07001114// initConvo initializes the conversation.
1115// It must not be called until all agent fields are initialized,
1116// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001117func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001118 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001119 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -07001120 convo.PromptCaching = true
1121 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001122 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001123 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001124
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001125 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1126 bashPermissionCheck := func(command string) error {
1127 // Check if branch name is set
1128 a.mu.Lock()
Philip Zeyligerf2872992025-05-22 10:35:28 -07001129 branchSet := a.gitState.BranchName() != ""
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001130 a.mu.Unlock()
1131
1132 // If branch is set, all commands are allowed
1133 if branchSet {
1134 return nil
1135 }
1136
1137 // If branch is not set, check if this is a git commit command
1138 willCommit, err := bashkit.WillRunGitCommit(command)
1139 if err != nil {
1140 // If there's an error checking, we should allow the command to proceed
1141 return nil
1142 }
1143
1144 // If it's a git commit and branch is not set, return an error
1145 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001146 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001147 }
1148
1149 return nil
1150 }
1151
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001152 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001153
Earl Lee2e463fb2025-04-17 11:22:22 -07001154 // Register all tools with the conversation
1155 // When adding, removing, or modifying tools here, double-check that the termui tool display
1156 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001157
1158 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001159 _, supportsScreenshots := a.config.Service.(*ant.Service)
1160 var bTools []*llm.Tool
1161 var browserCleanup func()
1162
1163 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1164 // Add cleanup function to context cancel
1165 go func() {
1166 <-a.config.Context.Done()
1167 browserCleanup()
1168 }()
1169 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001170
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001171 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001172 bashTool, claudetool.Keyword, claudetool.Patch,
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001173 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001174 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001175 }
1176
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001177 // One-shot mode is non-interactive, multiple choice requires human response
1178 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001179 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001180 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001181
1182 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -07001183 convo.Listener = a
1184 return convo
1185}
1186
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001187var multipleChoiceTool = &llm.Tool{
1188 Name: "multiplechoice",
1189 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.",
1190 EndsTurn: true,
1191 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001192 "type": "object",
1193 "description": "The question and a list of answers you would expect the user to choose from.",
1194 "properties": {
1195 "question": {
1196 "type": "string",
1197 "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?'"
1198 },
1199 "responseOptions": {
1200 "type": "array",
1201 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1202 "items": {
1203 "type": "object",
1204 "properties": {
1205 "caption": {
1206 "type": "string",
1207 "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'"
1208 },
1209 "responseText": {
1210 "type": "string",
1211 "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'"
1212 }
1213 },
1214 "required": ["caption", "responseText"]
1215 }
1216 }
1217 },
1218 "required": ["question", "responseOptions"]
1219}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001220 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1221 // The Run logic for "multiplechoice" tool is a no-op on the server.
1222 // The UI will present a list of options for the user to select from,
1223 // and that's it as far as "executing" the tool_use goes.
1224 // When the user *does* select one of the presented options, that
1225 // responseText gets sent as a chat message on behalf of the user.
1226 return llm.TextContent("end your turn and wait for the user to respond"), nil
1227 },
Sean McCullough485afc62025-04-28 14:28:39 -07001228}
1229
1230type MultipleChoiceOption struct {
1231 Caption string `json:"caption"`
1232 ResponseText string `json:"responseText"`
1233}
1234
1235type MultipleChoiceParams struct {
1236 Question string `json:"question"`
1237 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1238}
1239
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001240// branchExists reports whether branchName exists, either locally or in well-known remotes.
1241func branchExists(dir, branchName string) bool {
1242 refs := []string{
1243 "refs/heads/",
1244 "refs/remotes/origin/",
1245 "refs/remotes/sketch-host/",
1246 }
1247 for _, ref := range refs {
1248 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1249 cmd.Dir = dir
1250 if cmd.Run() == nil { // exit code 0 means branch exists
1251 return true
1252 }
1253 }
1254 return false
1255}
1256
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001257func (a *Agent) titleTool() *llm.Tool {
1258 description := `Sets the conversation title.`
1259 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001260 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001261 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001262 InputSchema: json.RawMessage(`{
1263 "type": "object",
1264 "properties": {
1265 "title": {
1266 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001267 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001268 }
1269 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001270 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001271}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001272 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001273 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001274 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001275 }
1276 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001277 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001278 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001279
1280 // We don't allow changing the title once set to be consistent with the previous behavior
1281 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001282 t := a.Title()
1283 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001284 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001285 }
1286
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001287 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001288 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001289 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001290
1291 a.SetTitle(params.Title)
1292 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001293 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001294 },
1295 }
1296 return titleTool
1297}
1298
1299func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001300 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 +00001301 preCommit := &llm.Tool{
1302 Name: "precommit",
1303 Description: description,
1304 InputSchema: json.RawMessage(`{
1305 "type": "object",
1306 "properties": {
1307 "branch_name": {
1308 "type": "string",
1309 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1310 }
1311 },
1312 "required": ["branch_name"]
1313}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001314 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001315 var params struct {
1316 BranchName string `json:"branch_name"`
1317 }
1318 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001319 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001320 }
1321
1322 b := a.BranchName()
1323 if b != "" {
Josh Bleecher Snyder44d1f1a2025-05-12 19:18:32 -07001324 return nil, fmt.Errorf("branch already set to %s; do not create a new branch", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001325 }
1326
1327 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001328 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001329 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001330 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001331 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001332 }
1333 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001334 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001335 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001336 }
1337
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001338 a.SetBranch(branchName)
Josh Bleecher Snyderf7bebdd2025-05-14 15:22:24 -07001339 response := fmt.Sprintf("switched to branch sketch/%q - DO NOT change branches unless explicitly requested", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001340
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001341 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1342 if err != nil {
1343 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1344 }
1345 if len(styleHint) > 0 {
1346 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001347 }
1348
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001349 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001350 },
1351 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001352 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001353}
1354
1355func (a *Agent) Ready() <-chan struct{} {
1356 return a.ready
1357}
1358
1359func (a *Agent) UserMessage(ctx context.Context, msg string) {
1360 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1361 a.inbox <- msg
1362}
1363
Earl Lee2e463fb2025-04-17 11:22:22 -07001364func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1365 return a.convo.CancelToolUse(toolUseID, cause)
1366}
1367
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001368func (a *Agent) CancelTurn(cause error) {
1369 a.cancelTurnMu.Lock()
1370 defer a.cancelTurnMu.Unlock()
1371 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001372 // Force state transition to cancelled state
1373 ctx := a.config.Context
1374 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001375 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001376 }
1377}
1378
1379func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001380 // Start port monitoring when the agent loop begins
1381 // Only monitor ports when running in a container
1382 if a.IsInContainer() {
1383 a.portMonitor.Start(ctxOuter)
1384 }
1385
Earl Lee2e463fb2025-04-17 11:22:22 -07001386 for {
1387 select {
1388 case <-ctxOuter.Done():
1389 return
1390 default:
1391 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001392 a.cancelTurnMu.Lock()
1393 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001394 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001395 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001396 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001397 a.cancelTurn = cancel
1398 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001399 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1400 if err != nil {
1401 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1402 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001403 cancel(nil)
1404 }
1405 }
1406}
1407
1408func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1409 if m.Timestamp.IsZero() {
1410 m.Timestamp = time.Now()
1411 }
1412
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001413 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1414 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1415 m.Content = m.ToolResult
1416 }
1417
Earl Lee2e463fb2025-04-17 11:22:22 -07001418 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1419 if m.EndOfTurn && m.Type == AgentMessageType {
1420 turnDuration := time.Since(a.startOfTurn)
1421 m.TurnDuration = &turnDuration
1422 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1423 }
1424
Earl Lee2e463fb2025-04-17 11:22:22 -07001425 a.mu.Lock()
1426 defer a.mu.Unlock()
1427 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001428 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001429 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001430
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001431 // Notify all subscribers
1432 for _, ch := range a.subscribers {
1433 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001434 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001435}
1436
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001437func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1438 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001439 if block {
1440 select {
1441 case <-ctx.Done():
1442 return m, ctx.Err()
1443 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001444 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001445 }
1446 }
1447 for {
1448 select {
1449 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001450 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001451 default:
1452 return m, nil
1453 }
1454 }
1455}
1456
Sean McCullough885a16a2025-04-30 02:49:25 +00001457// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001458func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001459 // Reset the start of turn time
1460 a.startOfTurn = time.Now()
1461
Sean McCullough96b60dd2025-04-30 09:49:10 -07001462 // Transition to waiting for user input state
1463 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1464
Sean McCullough885a16a2025-04-30 02:49:25 +00001465 // Process initial user message
1466 initialResp, err := a.processUserMessage(ctx)
1467 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001468 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001469 return err
1470 }
1471
1472 // Handle edge case where both initialResp and err are nil
1473 if initialResp == nil {
1474 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001475 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1476
Sean McCullough9f4b8082025-04-30 17:34:07 +00001477 a.pushToOutbox(ctx, errorMessage(err))
1478 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001479 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001480
Earl Lee2e463fb2025-04-17 11:22:22 -07001481 // We do this as we go, but let's also do it at the end of the turn
1482 defer func() {
1483 if _, err := a.handleGitCommits(ctx); err != nil {
1484 // Just log the error, don't stop execution
1485 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1486 }
1487 }()
1488
Sean McCullougha1e0e492025-05-01 10:51:08 -07001489 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001490 resp := initialResp
1491 for {
1492 // Check if we are over budget
1493 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001494 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001495 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001496 }
1497
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001498 // Check if we should compact the conversation
1499 if a.ShouldCompact() {
1500 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1501 if err := a.CompactConversation(ctx); err != nil {
1502 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1503 return err
1504 }
1505 // After compaction, end this turn and start fresh
1506 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1507 return nil
1508 }
1509
Sean McCullough885a16a2025-04-30 02:49:25 +00001510 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001511 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001512 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001513 break
1514 }
1515
Sean McCullough96b60dd2025-04-30 09:49:10 -07001516 // Transition to tool use requested state
1517 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1518
Sean McCullough885a16a2025-04-30 02:49:25 +00001519 // Handle tool execution
1520 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1521 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001522 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001523 }
1524
Sean McCullougha1e0e492025-05-01 10:51:08 -07001525 if toolResp == nil {
1526 return fmt.Errorf("cannot continue conversation with a nil tool response")
1527 }
1528
Sean McCullough885a16a2025-04-30 02:49:25 +00001529 // Set the response for the next iteration
1530 resp = toolResp
1531 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001532
1533 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001534}
1535
1536// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001537func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001538 // Wait for at least one message from the user
1539 msgs, err := a.GatherMessages(ctx, true)
1540 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001541 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001542 return nil, err
1543 }
1544
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001545 userMessage := llm.Message{
1546 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001547 Content: msgs,
1548 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001549
Sean McCullough96b60dd2025-04-30 09:49:10 -07001550 // Transition to sending to LLM state
1551 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1552
Sean McCullough885a16a2025-04-30 02:49:25 +00001553 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001554 resp, err := a.convo.SendMessage(userMessage)
1555 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001556 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001557 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001558 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001559 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001560
Sean McCullough96b60dd2025-04-30 09:49:10 -07001561 // Transition to processing LLM response state
1562 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1563
Sean McCullough885a16a2025-04-30 02:49:25 +00001564 return resp, nil
1565}
1566
1567// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001568func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1569 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001570 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001571 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001572
Sean McCullough96b60dd2025-04-30 09:49:10 -07001573 // Transition to checking for cancellation state
1574 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1575
Sean McCullough885a16a2025-04-30 02:49:25 +00001576 // Check if the operation was cancelled by the user
1577 select {
1578 case <-ctx.Done():
1579 // Don't actually run any of the tools, but rather build a response
1580 // for each tool_use message letting the LLM know that user canceled it.
1581 var err error
1582 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001583 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001584 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001585 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001586 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001587 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001588 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001589 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001590 // Transition to running tool state
1591 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1592
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001593 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001594 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001595 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001596
1597 // Execute the tools
1598 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001599 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001600 if ctx.Err() != nil { // e.g. the user canceled the operation
1601 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001602 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001603 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001604 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001605 a.pushToOutbox(ctx, errorMessage(err))
1606 }
1607 }
1608
1609 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001610 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001611 autoqualityMessages := a.processGitChanges(ctx)
1612
1613 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001614 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001615 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001616 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001617 return false, nil
1618 }
1619
1620 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001621 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1622 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001623}
1624
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001625// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001626func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001627 // Check for git commits
1628 _, err := a.handleGitCommits(ctx)
1629 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001630 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001631 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001632 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001633 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001634}
1635
1636// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1637// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001638func (a *Agent) processGitChanges(ctx context.Context) []string {
1639 // Check for git commits after tool execution
1640 newCommits, err := a.handleGitCommits(ctx)
1641 if err != nil {
1642 // Just log the error, don't stop execution
1643 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1644 return nil
1645 }
1646
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001647 // Run mechanical checks if there was exactly one new commit.
1648 if len(newCommits) != 1 {
1649 return nil
1650 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001651 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001652 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1653 msg := a.codereview.RunMechanicalChecks(ctx)
1654 if msg != "" {
1655 a.pushToOutbox(ctx, AgentMessage{
1656 Type: AutoMessageType,
1657 Content: msg,
1658 Timestamp: time.Now(),
1659 })
1660 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001661 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001662
1663 return autoqualityMessages
1664}
1665
1666// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001667func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001668 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001669 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001670 msgs, err := a.GatherMessages(ctx, false)
1671 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001672 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001673 return false, nil
1674 }
1675
1676 // Inject any auto-generated messages from quality checks
1677 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001678 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001679 }
1680
1681 // Handle cancellation by appending a message about it
1682 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001683 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001684 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001685 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001686 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1687 } else if err := a.convo.OverBudget(); err != nil {
1688 // Handle budget issues by appending a message about it
1689 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 -07001690 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001691 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1692 }
1693
1694 // Combine tool results with user messages
1695 results = append(results, msgs...)
1696
1697 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001698 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001699 resp, err := a.convo.SendMessage(llm.Message{
1700 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001701 Content: results,
1702 })
1703 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001704 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001705 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1706 return true, nil // Return true to continue the conversation, but with no response
1707 }
1708
Sean McCullough96b60dd2025-04-30 09:49:10 -07001709 // Transition back to processing LLM response
1710 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1711
Sean McCullough885a16a2025-04-30 02:49:25 +00001712 if cancelled {
1713 return false, nil
1714 }
1715
1716 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001717}
1718
1719func (a *Agent) overBudget(ctx context.Context) error {
1720 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001721 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001722 m := budgetMessage(err)
1723 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001724 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001725 a.convo.ResetBudget(a.originalBudget)
1726 return err
1727 }
1728 return nil
1729}
1730
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001731func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001732 // Collect all text content
1733 var allText strings.Builder
1734 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001735 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001736 if allText.Len() > 0 {
1737 allText.WriteString("\n\n")
1738 }
1739 allText.WriteString(content.Text)
1740 }
1741 }
1742 return allText.String()
1743}
1744
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001745func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001746 a.mu.Lock()
1747 defer a.mu.Unlock()
1748 return a.convo.CumulativeUsage()
1749}
1750
Earl Lee2e463fb2025-04-17 11:22:22 -07001751// Diff returns a unified diff of changes made since the agent was instantiated.
1752func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001753 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001754 return "", fmt.Errorf("no initial commit reference available")
1755 }
1756
1757 // Find the repository root
1758 ctx := context.Background()
1759
1760 // If a specific commit hash is provided, show just that commit's changes
1761 if commit != nil && *commit != "" {
1762 // Validate that the commit looks like a valid git SHA
1763 if !isValidGitSHA(*commit) {
1764 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1765 }
1766
1767 // Get the diff for just this commit
1768 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1769 cmd.Dir = a.repoRoot
1770 output, err := cmd.CombinedOutput()
1771 if err != nil {
1772 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1773 }
1774 return string(output), nil
1775 }
1776
1777 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001778 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001779 cmd.Dir = a.repoRoot
1780 output, err := cmd.CombinedOutput()
1781 if err != nil {
1782 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1783 }
1784
1785 return string(output), nil
1786}
1787
Philip Zeyliger49edc922025-05-14 09:45:45 -07001788// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1789// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1790func (a *Agent) SketchGitBaseRef() string {
1791 if a.IsInContainer() {
1792 return "sketch-base"
1793 } else {
1794 return "sketch-base-" + a.SessionID()
1795 }
1796}
1797
1798// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1799func (a *Agent) SketchGitBase() string {
1800 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1801 cmd.Dir = a.repoRoot
1802 output, err := cmd.CombinedOutput()
1803 if err != nil {
1804 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1805 return "HEAD"
1806 }
1807 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001808}
1809
Pokey Rule7a113622025-05-12 10:58:45 +01001810// removeGitHooks removes the Git hooks directory from the repository
1811func removeGitHooks(_ context.Context, repoPath string) error {
1812 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1813
1814 // Check if hooks directory exists
1815 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1816 // Directory doesn't exist, nothing to do
1817 return nil
1818 }
1819
1820 // Remove the hooks directory
1821 err := os.RemoveAll(hooksDir)
1822 if err != nil {
1823 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1824 }
1825
1826 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001827 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001828 if err != nil {
1829 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1830 }
1831
1832 return nil
1833}
1834
Philip Zeyligerf2872992025-05-22 10:35:28 -07001835func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1836 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef())
1837 for _, msg := range msgs {
1838 a.pushToOutbox(ctx, msg)
1839 }
1840 return commits, error
1841}
1842
Earl Lee2e463fb2025-04-17 11:22:22 -07001843// handleGitCommits() highlights new commits to the user. When running
1844// under docker, new HEADs are pushed to a branch according to the title.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001845func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string) ([]AgentMessage, []*GitCommit, error) {
1846 ags.mu.Lock()
1847 defer ags.mu.Unlock()
1848
1849 msgs := []AgentMessage{}
1850 if repoRoot == "" {
1851 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001852 }
1853
Philip Zeyligerf2872992025-05-22 10:35:28 -07001854 head, err := resolveRef(ctx, repoRoot, "HEAD")
Earl Lee2e463fb2025-04-17 11:22:22 -07001855 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001856 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001857 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001858 if head == ags.lastHEAD {
1859 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001860 }
1861 defer func() {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001862 ags.lastHEAD = head
Earl Lee2e463fb2025-04-17 11:22:22 -07001863 }()
1864
1865 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1866 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1867 // to the last 100 commits.
1868 var commits []*GitCommit
1869
1870 // Get commits since the initial commit
1871 // Format: <hash>\0<subject>\0<body>\0
1872 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1873 // Limit to 100 commits to avoid overwhelming the user
Philip Zeyligerf2872992025-05-22 10:35:28 -07001874 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, head)
1875 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001876 output, err := cmd.Output()
1877 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001878 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001879 }
1880
1881 // Parse git log output and filter out already seen commits
1882 parsedCommits := parseGitLog(string(output))
1883
1884 var headCommit *GitCommit
1885
1886 // Filter out commits we've already seen
1887 for _, commit := range parsedCommits {
1888 if commit.Hash == head {
1889 headCommit = &commit
1890 }
1891
1892 // Skip if we've seen this commit before. If our head has changed, always include that.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001893 if ags.seenCommits[commit.Hash] && commit.Hash != head {
Earl Lee2e463fb2025-04-17 11:22:22 -07001894 continue
1895 }
1896
1897 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001898 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001899
1900 // Add to our list of new commits
1901 commits = append(commits, &commit)
1902 }
1903
Philip Zeyligerf2872992025-05-22 10:35:28 -07001904 if ags.gitRemoteAddr != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001905 if headCommit == nil {
1906 // I think this can only happen if we have a bug or if there's a race.
1907 headCommit = &GitCommit{}
1908 headCommit.Hash = head
1909 headCommit.Subject = "unknown"
1910 commits = append(commits, headCommit)
1911 }
1912
Philip Zeyligerf2872992025-05-22 10:35:28 -07001913 originalBranch := cmp.Or(ags.branchName, "sketch/"+sessionID)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001914 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001915
1916 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1917 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1918 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001919
1920 // 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 {
1925 // Add a numeric suffix to the branch name
1926 branch = fmt.Sprintf("%s%d", originalBranch, retries)
1927 }
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 Zeyliger113e2052025-05-09 21:59:40 +00001957 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001958 }
1959 }
1960
1961 // If we found new commits, create a message
1962 if len(commits) > 0 {
1963 msg := AgentMessage{
1964 Type: CommitMessageType,
1965 Timestamp: time.Now(),
1966 Commits: commits,
1967 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001968 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001969 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001970 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001971}
1972
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001973func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001974 return strings.Map(func(r rune) rune {
1975 // lowercase
1976 if r >= 'A' && r <= 'Z' {
1977 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001978 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001979 // replace spaces with dashes
1980 if r == ' ' {
1981 return '-'
1982 }
1983 // allow alphanumerics and dashes
1984 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1985 return r
1986 }
1987 return -1
1988 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001989}
1990
1991// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1992// and returns an array of GitCommit structs.
1993func parseGitLog(output string) []GitCommit {
1994 var commits []GitCommit
1995
1996 // No output means no commits
1997 if len(output) == 0 {
1998 return commits
1999 }
2000
2001 // Split by NULL byte
2002 parts := strings.Split(output, "\x00")
2003
2004 // Process in triplets (hash, subject, body)
2005 for i := 0; i < len(parts); i++ {
2006 // Skip empty parts
2007 if parts[i] == "" {
2008 continue
2009 }
2010
2011 // This should be a hash
2012 hash := strings.TrimSpace(parts[i])
2013
2014 // Make sure we have at least a subject part available
2015 if i+1 >= len(parts) {
2016 break // No more parts available
2017 }
2018
2019 // Get the subject
2020 subject := strings.TrimSpace(parts[i+1])
2021
2022 // Get the body if available
2023 body := ""
2024 if i+2 < len(parts) {
2025 body = strings.TrimSpace(parts[i+2])
2026 }
2027
2028 // Skip to the next triplet
2029 i += 2
2030
2031 commits = append(commits, GitCommit{
2032 Hash: hash,
2033 Subject: subject,
2034 Body: body,
2035 })
2036 }
2037
2038 return commits
2039}
2040
2041func repoRoot(ctx context.Context, dir string) (string, error) {
2042 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2043 stderr := new(strings.Builder)
2044 cmd.Stderr = stderr
2045 cmd.Dir = dir
2046 out, err := cmd.Output()
2047 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002048 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002049 }
2050 return strings.TrimSpace(string(out)), nil
2051}
2052
2053func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2054 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2055 stderr := new(strings.Builder)
2056 cmd.Stderr = stderr
2057 cmd.Dir = dir
2058 out, err := cmd.Output()
2059 if err != nil {
2060 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2061 }
2062 // TODO: validate that out is valid hex
2063 return strings.TrimSpace(string(out)), nil
2064}
2065
2066// isValidGitSHA validates if a string looks like a valid git SHA hash.
2067// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2068func isValidGitSHA(sha string) bool {
2069 // Git SHA must be a hexadecimal string with at least 4 characters
2070 if len(sha) < 4 || len(sha) > 40 {
2071 return false
2072 }
2073
2074 // Check if the string only contains hexadecimal characters
2075 for _, char := range sha {
2076 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2077 return false
2078 }
2079 }
2080
2081 return true
2082}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002083
2084// getGitOrigin returns the URL of the git remote 'origin' if it exists
2085func getGitOrigin(ctx context.Context, dir string) string {
2086 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2087 cmd.Dir = dir
2088 stderr := new(strings.Builder)
2089 cmd.Stderr = stderr
2090 out, err := cmd.Output()
2091 if err != nil {
2092 return ""
2093 }
2094 return strings.TrimSpace(string(out))
2095}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002096
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002097// systemPromptData contains the data used to render the system prompt template
2098type systemPromptData struct {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002099 ClientGOOS string
2100 ClientGOARCH string
2101 WorkingDir string
2102 RepoRoot string
2103 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002104 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002105}
2106
2107// renderSystemPrompt renders the system prompt template.
2108func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002109 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002110 ClientGOOS: a.config.ClientGOOS,
2111 ClientGOARCH: a.config.ClientGOARCH,
2112 WorkingDir: a.workingDir,
2113 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002114 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002115 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002116 }
2117
2118 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2119 if err != nil {
2120 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2121 }
2122 buf := new(strings.Builder)
2123 err = tmpl.Execute(buf, data)
2124 if err != nil {
2125 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2126 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002127 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002128 return buf.String()
2129}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002130
2131// StateTransitionIterator provides an iterator over state transitions.
2132type StateTransitionIterator interface {
2133 // Next blocks until a new state transition is available or context is done.
2134 // Returns nil if the context is cancelled.
2135 Next() *StateTransition
2136 // Close removes the listener and cleans up resources.
2137 Close()
2138}
2139
2140// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2141type StateTransitionIteratorImpl struct {
2142 agent *Agent
2143 ctx context.Context
2144 ch chan StateTransition
2145 unsubscribe func()
2146}
2147
2148// Next blocks until a new state transition is available or the context is cancelled.
2149func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2150 select {
2151 case <-s.ctx.Done():
2152 return nil
2153 case transition, ok := <-s.ch:
2154 if !ok {
2155 return nil
2156 }
2157 transitionCopy := transition
2158 return &transitionCopy
2159 }
2160}
2161
2162// Close removes the listener and cleans up resources.
2163func (s *StateTransitionIteratorImpl) Close() {
2164 if s.unsubscribe != nil {
2165 s.unsubscribe()
2166 s.unsubscribe = nil
2167 }
2168}
2169
2170// NewStateTransitionIterator returns an iterator that receives state transitions.
2171func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2172 a.mu.Lock()
2173 defer a.mu.Unlock()
2174
2175 // Create channel to receive state transitions
2176 ch := make(chan StateTransition, 10)
2177
2178 // Add a listener to the state machine
2179 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2180
2181 return &StateTransitionIteratorImpl{
2182 agent: a,
2183 ctx: ctx,
2184 ch: ch,
2185 unsubscribe: unsubscribe,
2186 }
2187}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002188
2189// setupGitHooks creates or updates git hooks in the specified working directory.
2190func setupGitHooks(workingDir string) error {
2191 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2192
2193 _, err := os.Stat(hooksDir)
2194 if os.IsNotExist(err) {
2195 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2196 }
2197 if err != nil {
2198 return fmt.Errorf("error checking git hooks directory: %w", err)
2199 }
2200
2201 // Define the post-commit hook content
2202 postCommitHook := `#!/bin/bash
2203echo "<post_commit_hook>"
2204echo "Please review this commit message and fix it if it is incorrect."
2205echo "This hook only echos the commit message; it does not modify it."
2206echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2207echo "<last_commit_message>"
2208git log -1 --pretty=%B
2209echo "</last_commit_message>"
2210echo "</post_commit_hook>"
2211`
2212
2213 // Define the prepare-commit-msg hook content
2214 prepareCommitMsgHook := `#!/bin/bash
2215# Add Co-Authored-By and Change-ID trailers to commit messages
2216# Check if these trailers already exist before adding them
2217
2218commit_file="$1"
2219COMMIT_SOURCE="$2"
2220
2221# Skip for merges, squashes, or when using a commit template
2222if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2223 [ "$COMMIT_SOURCE" = "squash" ]; then
2224 exit 0
2225fi
2226
2227commit_msg=$(cat "$commit_file")
2228
2229needs_co_author=true
2230needs_change_id=true
2231
2232# Check if commit message already has Co-Authored-By trailer
2233if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2234 needs_co_author=false
2235fi
2236
2237# Check if commit message already has Change-ID trailer
2238if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2239 needs_change_id=false
2240fi
2241
2242# Only modify if at least one trailer needs to be added
2243if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002244 # Ensure there's a proper blank line before trailers
2245 if [ -s "$commit_file" ]; then
2246 # Check if file ends with newline by reading last character
2247 last_char=$(tail -c 1 "$commit_file")
2248
2249 if [ "$last_char" != "" ]; then
2250 # File doesn't end with newline - add two newlines (complete line + blank line)
2251 echo "" >> "$commit_file"
2252 echo "" >> "$commit_file"
2253 else
2254 # File ends with newline - check if we already have a blank line
2255 last_line=$(tail -1 "$commit_file")
2256 if [ -n "$last_line" ]; then
2257 # Last line has content - add one newline for blank line
2258 echo "" >> "$commit_file"
2259 fi
2260 # If last line is empty, we already have a blank line - don't add anything
2261 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002262 fi
2263
2264 # Add trailers if needed
2265 if [ "$needs_co_author" = true ]; then
2266 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2267 fi
2268
2269 if [ "$needs_change_id" = true ]; then
2270 change_id=$(openssl rand -hex 8)
2271 echo "Change-ID: s${change_id}k" >> "$commit_file"
2272 fi
2273fi
2274`
2275
2276 // Update or create the post-commit hook
2277 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2278 if err != nil {
2279 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2280 }
2281
2282 // Update or create the prepare-commit-msg hook
2283 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2284 if err != nil {
2285 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2286 }
2287
2288 return nil
2289}
2290
2291// updateOrCreateHook creates a new hook file or updates an existing one
2292// by appending the new content if it doesn't already contain it.
2293func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2294 // Check if the hook already exists
2295 buf, err := os.ReadFile(hookPath)
2296 if os.IsNotExist(err) {
2297 // Hook doesn't exist, create it
2298 err = os.WriteFile(hookPath, []byte(content), 0o755)
2299 if err != nil {
2300 return fmt.Errorf("failed to create hook: %w", err)
2301 }
2302 return nil
2303 }
2304 if err != nil {
2305 return fmt.Errorf("error reading existing hook: %w", err)
2306 }
2307
2308 // Hook exists, check if our content is already in it by looking for a distinctive line
2309 code := string(buf)
2310 if strings.Contains(code, distinctiveLine) {
2311 // Already contains our content, nothing to do
2312 return nil
2313 }
2314
2315 // Append our content to the existing hook
2316 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2317 if err != nil {
2318 return fmt.Errorf("failed to open hook for appending: %w", err)
2319 }
2320 defer f.Close()
2321
2322 // Ensure there's a newline at the end of the existing content if needed
2323 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2324 _, err = f.WriteString("\n")
2325 if err != nil {
2326 return fmt.Errorf("failed to add newline to hook: %w", err)
2327 }
2328 }
2329
2330 // Add a separator before our content
2331 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2332 if err != nil {
2333 return fmt.Errorf("failed to append to hook: %w", err)
2334 }
2335
2336 return nil
2337}