blob: 886ea726172408f15940cbae17abb6a95b8c19de [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
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001080 if a.config.InDocker {
1081 if err := setupGitHooks(a.repoRoot); err != nil {
1082 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1083 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001084 }
1085
Philip Zeyliger49edc922025-05-14 09:45:45 -07001086 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1087 cmd.Dir = repoRoot
1088 if out, err := cmd.CombinedOutput(); err != nil {
1089 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1090 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001091
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001092 slog.Info("running codebase analysis")
1093 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1094 if err != nil {
1095 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001096 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001097 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001098
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001099 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001100 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001101 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001102 }
1103 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001104
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001105 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -07001106 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001107 a.gitState.lastHEAD = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001108 a.convo = a.initConvo()
1109 close(a.ready)
1110 return nil
1111}
1112
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001113//go:embed agent_system_prompt.txt
1114var agentSystemPrompt string
1115
Earl Lee2e463fb2025-04-17 11:22:22 -07001116// initConvo initializes the conversation.
1117// It must not be called until all agent fields are initialized,
1118// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001119func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001120 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001121 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -07001122 convo.PromptCaching = true
1123 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001124 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001125 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001126
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001127 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1128 bashPermissionCheck := func(command string) error {
1129 // Check if branch name is set
1130 a.mu.Lock()
Philip Zeyligerf2872992025-05-22 10:35:28 -07001131 branchSet := a.gitState.BranchName() != ""
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001132 a.mu.Unlock()
1133
1134 // If branch is set, all commands are allowed
1135 if branchSet {
1136 return nil
1137 }
1138
1139 // If branch is not set, check if this is a git commit command
1140 willCommit, err := bashkit.WillRunGitCommit(command)
1141 if err != nil {
1142 // If there's an error checking, we should allow the command to proceed
1143 return nil
1144 }
1145
1146 // If it's a git commit and branch is not set, return an error
1147 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001148 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001149 }
1150
1151 return nil
1152 }
1153
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001154 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001155
Earl Lee2e463fb2025-04-17 11:22:22 -07001156 // Register all tools with the conversation
1157 // When adding, removing, or modifying tools here, double-check that the termui tool display
1158 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001159
1160 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001161 _, supportsScreenshots := a.config.Service.(*ant.Service)
1162 var bTools []*llm.Tool
1163 var browserCleanup func()
1164
1165 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1166 // Add cleanup function to context cancel
1167 go func() {
1168 <-a.config.Context.Done()
1169 browserCleanup()
1170 }()
1171 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001172
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001173 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001174 bashTool, claudetool.Keyword, claudetool.Patch,
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001175 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001176 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001177 }
1178
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001179 // One-shot mode is non-interactive, multiple choice requires human response
1180 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001181 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001182 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001183
1184 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -07001185 convo.Listener = a
1186 return convo
1187}
1188
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001189var multipleChoiceTool = &llm.Tool{
1190 Name: "multiplechoice",
1191 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.",
1192 EndsTurn: true,
1193 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001194 "type": "object",
1195 "description": "The question and a list of answers you would expect the user to choose from.",
1196 "properties": {
1197 "question": {
1198 "type": "string",
1199 "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?'"
1200 },
1201 "responseOptions": {
1202 "type": "array",
1203 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1204 "items": {
1205 "type": "object",
1206 "properties": {
1207 "caption": {
1208 "type": "string",
1209 "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'"
1210 },
1211 "responseText": {
1212 "type": "string",
1213 "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'"
1214 }
1215 },
1216 "required": ["caption", "responseText"]
1217 }
1218 }
1219 },
1220 "required": ["question", "responseOptions"]
1221}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001222 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1223 // The Run logic for "multiplechoice" tool is a no-op on the server.
1224 // The UI will present a list of options for the user to select from,
1225 // and that's it as far as "executing" the tool_use goes.
1226 // When the user *does* select one of the presented options, that
1227 // responseText gets sent as a chat message on behalf of the user.
1228 return llm.TextContent("end your turn and wait for the user to respond"), nil
1229 },
Sean McCullough485afc62025-04-28 14:28:39 -07001230}
1231
1232type MultipleChoiceOption struct {
1233 Caption string `json:"caption"`
1234 ResponseText string `json:"responseText"`
1235}
1236
1237type MultipleChoiceParams struct {
1238 Question string `json:"question"`
1239 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1240}
1241
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001242// branchExists reports whether branchName exists, either locally or in well-known remotes.
1243func branchExists(dir, branchName string) bool {
1244 refs := []string{
1245 "refs/heads/",
1246 "refs/remotes/origin/",
1247 "refs/remotes/sketch-host/",
1248 }
1249 for _, ref := range refs {
1250 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1251 cmd.Dir = dir
1252 if cmd.Run() == nil { // exit code 0 means branch exists
1253 return true
1254 }
1255 }
1256 return false
1257}
1258
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001259func (a *Agent) titleTool() *llm.Tool {
1260 description := `Sets the conversation title.`
1261 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001262 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001263 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001264 InputSchema: json.RawMessage(`{
1265 "type": "object",
1266 "properties": {
1267 "title": {
1268 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001269 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001270 }
1271 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001272 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001273}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001274 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001275 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001276 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001277 }
1278 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001279 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001280 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001281
1282 // We don't allow changing the title once set to be consistent with the previous behavior
1283 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001284 t := a.Title()
1285 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001286 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001287 }
1288
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001289 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001290 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001291 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001292
1293 a.SetTitle(params.Title)
1294 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001295 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001296 },
1297 }
1298 return titleTool
1299}
1300
1301func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001302 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 +00001303 preCommit := &llm.Tool{
1304 Name: "precommit",
1305 Description: description,
1306 InputSchema: json.RawMessage(`{
1307 "type": "object",
1308 "properties": {
1309 "branch_name": {
1310 "type": "string",
1311 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1312 }
1313 },
1314 "required": ["branch_name"]
1315}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001316 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001317 var params struct {
1318 BranchName string `json:"branch_name"`
1319 }
1320 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001321 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001322 }
1323
1324 b := a.BranchName()
1325 if b != "" {
Josh Bleecher Snyder44d1f1a2025-05-12 19:18:32 -07001326 return nil, fmt.Errorf("branch already set to %s; do not create a new branch", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001327 }
1328
1329 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001330 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001331 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001332 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001333 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001334 }
1335 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001336 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001337 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001338 }
1339
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001340 a.SetBranch(branchName)
Josh Bleecher Snyderf7bebdd2025-05-14 15:22:24 -07001341 response := fmt.Sprintf("switched to branch sketch/%q - DO NOT change branches unless explicitly requested", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001342
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001343 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1344 if err != nil {
1345 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1346 }
1347 if len(styleHint) > 0 {
1348 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001349 }
1350
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001351 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001352 },
1353 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001354 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001355}
1356
1357func (a *Agent) Ready() <-chan struct{} {
1358 return a.ready
1359}
1360
1361func (a *Agent) UserMessage(ctx context.Context, msg string) {
1362 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1363 a.inbox <- msg
1364}
1365
Earl Lee2e463fb2025-04-17 11:22:22 -07001366func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1367 return a.convo.CancelToolUse(toolUseID, cause)
1368}
1369
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001370func (a *Agent) CancelTurn(cause error) {
1371 a.cancelTurnMu.Lock()
1372 defer a.cancelTurnMu.Unlock()
1373 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001374 // Force state transition to cancelled state
1375 ctx := a.config.Context
1376 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001377 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001378 }
1379}
1380
1381func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001382 // Start port monitoring when the agent loop begins
1383 // Only monitor ports when running in a container
1384 if a.IsInContainer() {
1385 a.portMonitor.Start(ctxOuter)
1386 }
1387
Earl Lee2e463fb2025-04-17 11:22:22 -07001388 for {
1389 select {
1390 case <-ctxOuter.Done():
1391 return
1392 default:
1393 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001394 a.cancelTurnMu.Lock()
1395 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001396 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001397 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001398 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001399 a.cancelTurn = cancel
1400 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001401 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1402 if err != nil {
1403 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1404 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001405 cancel(nil)
1406 }
1407 }
1408}
1409
1410func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1411 if m.Timestamp.IsZero() {
1412 m.Timestamp = time.Now()
1413 }
1414
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001415 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1416 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1417 m.Content = m.ToolResult
1418 }
1419
Earl Lee2e463fb2025-04-17 11:22:22 -07001420 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1421 if m.EndOfTurn && m.Type == AgentMessageType {
1422 turnDuration := time.Since(a.startOfTurn)
1423 m.TurnDuration = &turnDuration
1424 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1425 }
1426
Earl Lee2e463fb2025-04-17 11:22:22 -07001427 a.mu.Lock()
1428 defer a.mu.Unlock()
1429 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001430 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001431 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001432
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001433 // Notify all subscribers
1434 for _, ch := range a.subscribers {
1435 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001436 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001437}
1438
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001439func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1440 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001441 if block {
1442 select {
1443 case <-ctx.Done():
1444 return m, ctx.Err()
1445 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001446 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001447 }
1448 }
1449 for {
1450 select {
1451 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001452 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001453 default:
1454 return m, nil
1455 }
1456 }
1457}
1458
Sean McCullough885a16a2025-04-30 02:49:25 +00001459// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001460func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001461 // Reset the start of turn time
1462 a.startOfTurn = time.Now()
1463
Sean McCullough96b60dd2025-04-30 09:49:10 -07001464 // Transition to waiting for user input state
1465 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1466
Sean McCullough885a16a2025-04-30 02:49:25 +00001467 // Process initial user message
1468 initialResp, err := a.processUserMessage(ctx)
1469 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001470 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001471 return err
1472 }
1473
1474 // Handle edge case where both initialResp and err are nil
1475 if initialResp == nil {
1476 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001477 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1478
Sean McCullough9f4b8082025-04-30 17:34:07 +00001479 a.pushToOutbox(ctx, errorMessage(err))
1480 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001481 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001482
Earl Lee2e463fb2025-04-17 11:22:22 -07001483 // We do this as we go, but let's also do it at the end of the turn
1484 defer func() {
1485 if _, err := a.handleGitCommits(ctx); err != nil {
1486 // Just log the error, don't stop execution
1487 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1488 }
1489 }()
1490
Sean McCullougha1e0e492025-05-01 10:51:08 -07001491 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001492 resp := initialResp
1493 for {
1494 // Check if we are over budget
1495 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001496 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001497 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001498 }
1499
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001500 // Check if we should compact the conversation
1501 if a.ShouldCompact() {
1502 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1503 if err := a.CompactConversation(ctx); err != nil {
1504 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1505 return err
1506 }
1507 // After compaction, end this turn and start fresh
1508 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1509 return nil
1510 }
1511
Sean McCullough885a16a2025-04-30 02:49:25 +00001512 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001513 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001514 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001515 break
1516 }
1517
Sean McCullough96b60dd2025-04-30 09:49:10 -07001518 // Transition to tool use requested state
1519 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1520
Sean McCullough885a16a2025-04-30 02:49:25 +00001521 // Handle tool execution
1522 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1523 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001524 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001525 }
1526
Sean McCullougha1e0e492025-05-01 10:51:08 -07001527 if toolResp == nil {
1528 return fmt.Errorf("cannot continue conversation with a nil tool response")
1529 }
1530
Sean McCullough885a16a2025-04-30 02:49:25 +00001531 // Set the response for the next iteration
1532 resp = toolResp
1533 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001534
1535 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001536}
1537
1538// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001539func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001540 // Wait for at least one message from the user
1541 msgs, err := a.GatherMessages(ctx, true)
1542 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001543 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001544 return nil, err
1545 }
1546
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001547 userMessage := llm.Message{
1548 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001549 Content: msgs,
1550 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001551
Sean McCullough96b60dd2025-04-30 09:49:10 -07001552 // Transition to sending to LLM state
1553 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1554
Sean McCullough885a16a2025-04-30 02:49:25 +00001555 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001556 resp, err := a.convo.SendMessage(userMessage)
1557 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001558 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001559 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001560 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001561 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001562
Sean McCullough96b60dd2025-04-30 09:49:10 -07001563 // Transition to processing LLM response state
1564 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1565
Sean McCullough885a16a2025-04-30 02:49:25 +00001566 return resp, nil
1567}
1568
1569// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001570func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1571 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001572 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001573 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001574
Sean McCullough96b60dd2025-04-30 09:49:10 -07001575 // Transition to checking for cancellation state
1576 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1577
Sean McCullough885a16a2025-04-30 02:49:25 +00001578 // Check if the operation was cancelled by the user
1579 select {
1580 case <-ctx.Done():
1581 // Don't actually run any of the tools, but rather build a response
1582 // for each tool_use message letting the LLM know that user canceled it.
1583 var err error
1584 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001585 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001586 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001587 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001588 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001589 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001590 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001591 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001592 // Transition to running tool state
1593 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1594
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001595 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001596 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001597 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001598
1599 // Execute the tools
1600 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001601 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001602 if ctx.Err() != nil { // e.g. the user canceled the operation
1603 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001604 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001605 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001606 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001607 a.pushToOutbox(ctx, errorMessage(err))
1608 }
1609 }
1610
1611 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001612 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001613 autoqualityMessages := a.processGitChanges(ctx)
1614
1615 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001616 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001617 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001618 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001619 return false, nil
1620 }
1621
1622 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001623 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1624 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001625}
1626
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001627// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001628func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001629 // Check for git commits
1630 _, err := a.handleGitCommits(ctx)
1631 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001632 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001633 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001634 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001635 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001636}
1637
1638// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1639// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001640func (a *Agent) processGitChanges(ctx context.Context) []string {
1641 // Check for git commits after tool execution
1642 newCommits, err := a.handleGitCommits(ctx)
1643 if err != nil {
1644 // Just log the error, don't stop execution
1645 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1646 return nil
1647 }
1648
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001649 // Run mechanical checks if there was exactly one new commit.
1650 if len(newCommits) != 1 {
1651 return nil
1652 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001653 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001654 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1655 msg := a.codereview.RunMechanicalChecks(ctx)
1656 if msg != "" {
1657 a.pushToOutbox(ctx, AgentMessage{
1658 Type: AutoMessageType,
1659 Content: msg,
1660 Timestamp: time.Now(),
1661 })
1662 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001663 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001664
1665 return autoqualityMessages
1666}
1667
1668// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001669func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001670 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001671 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001672 msgs, err := a.GatherMessages(ctx, false)
1673 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001674 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001675 return false, nil
1676 }
1677
1678 // Inject any auto-generated messages from quality checks
1679 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001680 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001681 }
1682
1683 // Handle cancellation by appending a message about it
1684 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001685 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001686 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001687 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001688 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1689 } else if err := a.convo.OverBudget(); err != nil {
1690 // Handle budget issues by appending a message about it
1691 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 -07001692 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001693 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1694 }
1695
1696 // Combine tool results with user messages
1697 results = append(results, msgs...)
1698
1699 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001700 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001701 resp, err := a.convo.SendMessage(llm.Message{
1702 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001703 Content: results,
1704 })
1705 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001706 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001707 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1708 return true, nil // Return true to continue the conversation, but with no response
1709 }
1710
Sean McCullough96b60dd2025-04-30 09:49:10 -07001711 // Transition back to processing LLM response
1712 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1713
Sean McCullough885a16a2025-04-30 02:49:25 +00001714 if cancelled {
1715 return false, nil
1716 }
1717
1718 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001719}
1720
1721func (a *Agent) overBudget(ctx context.Context) error {
1722 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001723 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001724 m := budgetMessage(err)
1725 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001726 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001727 a.convo.ResetBudget(a.originalBudget)
1728 return err
1729 }
1730 return nil
1731}
1732
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001733func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001734 // Collect all text content
1735 var allText strings.Builder
1736 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001737 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001738 if allText.Len() > 0 {
1739 allText.WriteString("\n\n")
1740 }
1741 allText.WriteString(content.Text)
1742 }
1743 }
1744 return allText.String()
1745}
1746
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001747func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001748 a.mu.Lock()
1749 defer a.mu.Unlock()
1750 return a.convo.CumulativeUsage()
1751}
1752
Earl Lee2e463fb2025-04-17 11:22:22 -07001753// Diff returns a unified diff of changes made since the agent was instantiated.
1754func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001755 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001756 return "", fmt.Errorf("no initial commit reference available")
1757 }
1758
1759 // Find the repository root
1760 ctx := context.Background()
1761
1762 // If a specific commit hash is provided, show just that commit's changes
1763 if commit != nil && *commit != "" {
1764 // Validate that the commit looks like a valid git SHA
1765 if !isValidGitSHA(*commit) {
1766 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1767 }
1768
1769 // Get the diff for just this commit
1770 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1771 cmd.Dir = a.repoRoot
1772 output, err := cmd.CombinedOutput()
1773 if err != nil {
1774 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1775 }
1776 return string(output), nil
1777 }
1778
1779 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001780 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001781 cmd.Dir = a.repoRoot
1782 output, err := cmd.CombinedOutput()
1783 if err != nil {
1784 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1785 }
1786
1787 return string(output), nil
1788}
1789
Philip Zeyliger49edc922025-05-14 09:45:45 -07001790// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1791// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1792func (a *Agent) SketchGitBaseRef() string {
1793 if a.IsInContainer() {
1794 return "sketch-base"
1795 } else {
1796 return "sketch-base-" + a.SessionID()
1797 }
1798}
1799
1800// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1801func (a *Agent) SketchGitBase() string {
1802 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1803 cmd.Dir = a.repoRoot
1804 output, err := cmd.CombinedOutput()
1805 if err != nil {
1806 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1807 return "HEAD"
1808 }
1809 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001810}
1811
Pokey Rule7a113622025-05-12 10:58:45 +01001812// removeGitHooks removes the Git hooks directory from the repository
1813func removeGitHooks(_ context.Context, repoPath string) error {
1814 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1815
1816 // Check if hooks directory exists
1817 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1818 // Directory doesn't exist, nothing to do
1819 return nil
1820 }
1821
1822 // Remove the hooks directory
1823 err := os.RemoveAll(hooksDir)
1824 if err != nil {
1825 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1826 }
1827
1828 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001829 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001830 if err != nil {
1831 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1832 }
1833
1834 return nil
1835}
1836
Philip Zeyligerf2872992025-05-22 10:35:28 -07001837func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1838 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef())
1839 for _, msg := range msgs {
1840 a.pushToOutbox(ctx, msg)
1841 }
1842 return commits, error
1843}
1844
Earl Lee2e463fb2025-04-17 11:22:22 -07001845// handleGitCommits() highlights new commits to the user. When running
1846// under docker, new HEADs are pushed to a branch according to the title.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001847func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string) ([]AgentMessage, []*GitCommit, error) {
1848 ags.mu.Lock()
1849 defer ags.mu.Unlock()
1850
1851 msgs := []AgentMessage{}
1852 if repoRoot == "" {
1853 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001854 }
1855
Philip Zeyligerf2872992025-05-22 10:35:28 -07001856 head, err := resolveRef(ctx, repoRoot, "HEAD")
Earl Lee2e463fb2025-04-17 11:22:22 -07001857 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001858 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001859 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001860 if head == ags.lastHEAD {
1861 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001862 }
1863 defer func() {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001864 ags.lastHEAD = head
Earl Lee2e463fb2025-04-17 11:22:22 -07001865 }()
1866
1867 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1868 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1869 // to the last 100 commits.
1870 var commits []*GitCommit
1871
1872 // Get commits since the initial commit
1873 // Format: <hash>\0<subject>\0<body>\0
1874 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1875 // Limit to 100 commits to avoid overwhelming the user
Philip Zeyligerf2872992025-05-22 10:35:28 -07001876 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, head)
1877 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001878 output, err := cmd.Output()
1879 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001880 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001881 }
1882
1883 // Parse git log output and filter out already seen commits
1884 parsedCommits := parseGitLog(string(output))
1885
1886 var headCommit *GitCommit
1887
1888 // Filter out commits we've already seen
1889 for _, commit := range parsedCommits {
1890 if commit.Hash == head {
1891 headCommit = &commit
1892 }
1893
1894 // Skip if we've seen this commit before. If our head has changed, always include that.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001895 if ags.seenCommits[commit.Hash] && commit.Hash != head {
Earl Lee2e463fb2025-04-17 11:22:22 -07001896 continue
1897 }
1898
1899 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001900 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001901
1902 // Add to our list of new commits
1903 commits = append(commits, &commit)
1904 }
1905
Philip Zeyligerf2872992025-05-22 10:35:28 -07001906 if ags.gitRemoteAddr != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001907 if headCommit == nil {
1908 // I think this can only happen if we have a bug or if there's a race.
1909 headCommit = &GitCommit{}
1910 headCommit.Hash = head
1911 headCommit.Subject = "unknown"
1912 commits = append(commits, headCommit)
1913 }
1914
Philip Zeyligerf2872992025-05-22 10:35:28 -07001915 originalBranch := cmp.Or(ags.branchName, "sketch/"+sessionID)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001916 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001917
1918 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1919 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1920 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001921
1922 // Try up to 10 times with different branch names if the branch is checked out on the remote
1923 var out []byte
1924 var err error
1925 for retries := range 10 {
1926 if retries > 0 {
1927 // Add a numeric suffix to the branch name
1928 branch = fmt.Sprintf("%s%d", originalBranch, retries)
1929 }
1930
Philip Zeyligerf2872992025-05-22 10:35:28 -07001931 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1932 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001933 out, err = cmd.CombinedOutput()
1934
1935 if err == nil {
1936 // Success! Break out of the retry loop
1937 break
1938 }
1939
1940 // Check if this is the "refusing to update checked out branch" error
1941 if !strings.Contains(string(out), "refusing to update checked out branch") {
1942 // This is a different error, so don't retry
1943 break
1944 }
1945
1946 // If we're on the last retry, we'll report the error
1947 if retries == 9 {
1948 break
1949 }
1950 }
1951
1952 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001953 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001954 } else {
1955 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001956 // Update the agent's branch name if we ended up using a different one
1957 if branch != originalBranch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001958 ags.branchName = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001959 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001960 }
1961 }
1962
1963 // If we found new commits, create a message
1964 if len(commits) > 0 {
1965 msg := AgentMessage{
1966 Type: CommitMessageType,
1967 Timestamp: time.Now(),
1968 Commits: commits,
1969 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001970 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001971 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001972 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001973}
1974
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001975func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001976 return strings.Map(func(r rune) rune {
1977 // lowercase
1978 if r >= 'A' && r <= 'Z' {
1979 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001980 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001981 // replace spaces with dashes
1982 if r == ' ' {
1983 return '-'
1984 }
1985 // allow alphanumerics and dashes
1986 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1987 return r
1988 }
1989 return -1
1990 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001991}
1992
1993// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1994// and returns an array of GitCommit structs.
1995func parseGitLog(output string) []GitCommit {
1996 var commits []GitCommit
1997
1998 // No output means no commits
1999 if len(output) == 0 {
2000 return commits
2001 }
2002
2003 // Split by NULL byte
2004 parts := strings.Split(output, "\x00")
2005
2006 // Process in triplets (hash, subject, body)
2007 for i := 0; i < len(parts); i++ {
2008 // Skip empty parts
2009 if parts[i] == "" {
2010 continue
2011 }
2012
2013 // This should be a hash
2014 hash := strings.TrimSpace(parts[i])
2015
2016 // Make sure we have at least a subject part available
2017 if i+1 >= len(parts) {
2018 break // No more parts available
2019 }
2020
2021 // Get the subject
2022 subject := strings.TrimSpace(parts[i+1])
2023
2024 // Get the body if available
2025 body := ""
2026 if i+2 < len(parts) {
2027 body = strings.TrimSpace(parts[i+2])
2028 }
2029
2030 // Skip to the next triplet
2031 i += 2
2032
2033 commits = append(commits, GitCommit{
2034 Hash: hash,
2035 Subject: subject,
2036 Body: body,
2037 })
2038 }
2039
2040 return commits
2041}
2042
2043func repoRoot(ctx context.Context, dir string) (string, error) {
2044 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2045 stderr := new(strings.Builder)
2046 cmd.Stderr = stderr
2047 cmd.Dir = dir
2048 out, err := cmd.Output()
2049 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002050 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002051 }
2052 return strings.TrimSpace(string(out)), nil
2053}
2054
2055func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2056 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2057 stderr := new(strings.Builder)
2058 cmd.Stderr = stderr
2059 cmd.Dir = dir
2060 out, err := cmd.Output()
2061 if err != nil {
2062 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2063 }
2064 // TODO: validate that out is valid hex
2065 return strings.TrimSpace(string(out)), nil
2066}
2067
2068// isValidGitSHA validates if a string looks like a valid git SHA hash.
2069// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2070func isValidGitSHA(sha string) bool {
2071 // Git SHA must be a hexadecimal string with at least 4 characters
2072 if len(sha) < 4 || len(sha) > 40 {
2073 return false
2074 }
2075
2076 // Check if the string only contains hexadecimal characters
2077 for _, char := range sha {
2078 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2079 return false
2080 }
2081 }
2082
2083 return true
2084}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002085
2086// getGitOrigin returns the URL of the git remote 'origin' if it exists
2087func getGitOrigin(ctx context.Context, dir string) string {
2088 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2089 cmd.Dir = dir
2090 stderr := new(strings.Builder)
2091 cmd.Stderr = stderr
2092 out, err := cmd.Output()
2093 if err != nil {
2094 return ""
2095 }
2096 return strings.TrimSpace(string(out))
2097}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002098
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002099// systemPromptData contains the data used to render the system prompt template
2100type systemPromptData struct {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002101 ClientGOOS string
2102 ClientGOARCH string
2103 WorkingDir string
2104 RepoRoot string
2105 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002106 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002107}
2108
2109// renderSystemPrompt renders the system prompt template.
2110func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002111 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002112 ClientGOOS: a.config.ClientGOOS,
2113 ClientGOARCH: a.config.ClientGOARCH,
2114 WorkingDir: a.workingDir,
2115 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002116 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002117 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002118 }
2119
2120 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2121 if err != nil {
2122 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2123 }
2124 buf := new(strings.Builder)
2125 err = tmpl.Execute(buf, data)
2126 if err != nil {
2127 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2128 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002129 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002130 return buf.String()
2131}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002132
2133// StateTransitionIterator provides an iterator over state transitions.
2134type StateTransitionIterator interface {
2135 // Next blocks until a new state transition is available or context is done.
2136 // Returns nil if the context is cancelled.
2137 Next() *StateTransition
2138 // Close removes the listener and cleans up resources.
2139 Close()
2140}
2141
2142// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2143type StateTransitionIteratorImpl struct {
2144 agent *Agent
2145 ctx context.Context
2146 ch chan StateTransition
2147 unsubscribe func()
2148}
2149
2150// Next blocks until a new state transition is available or the context is cancelled.
2151func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2152 select {
2153 case <-s.ctx.Done():
2154 return nil
2155 case transition, ok := <-s.ch:
2156 if !ok {
2157 return nil
2158 }
2159 transitionCopy := transition
2160 return &transitionCopy
2161 }
2162}
2163
2164// Close removes the listener and cleans up resources.
2165func (s *StateTransitionIteratorImpl) Close() {
2166 if s.unsubscribe != nil {
2167 s.unsubscribe()
2168 s.unsubscribe = nil
2169 }
2170}
2171
2172// NewStateTransitionIterator returns an iterator that receives state transitions.
2173func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2174 a.mu.Lock()
2175 defer a.mu.Unlock()
2176
2177 // Create channel to receive state transitions
2178 ch := make(chan StateTransition, 10)
2179
2180 // Add a listener to the state machine
2181 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2182
2183 return &StateTransitionIteratorImpl{
2184 agent: a,
2185 ctx: ctx,
2186 ch: ch,
2187 unsubscribe: unsubscribe,
2188 }
2189}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002190
2191// setupGitHooks creates or updates git hooks in the specified working directory.
2192func setupGitHooks(workingDir string) error {
2193 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2194
2195 _, err := os.Stat(hooksDir)
2196 if os.IsNotExist(err) {
2197 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2198 }
2199 if err != nil {
2200 return fmt.Errorf("error checking git hooks directory: %w", err)
2201 }
2202
2203 // Define the post-commit hook content
2204 postCommitHook := `#!/bin/bash
2205echo "<post_commit_hook>"
2206echo "Please review this commit message and fix it if it is incorrect."
2207echo "This hook only echos the commit message; it does not modify it."
2208echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2209echo "<last_commit_message>"
2210git log -1 --pretty=%B
2211echo "</last_commit_message>"
2212echo "</post_commit_hook>"
2213`
2214
2215 // Define the prepare-commit-msg hook content
2216 prepareCommitMsgHook := `#!/bin/bash
2217# Add Co-Authored-By and Change-ID trailers to commit messages
2218# Check if these trailers already exist before adding them
2219
2220commit_file="$1"
2221COMMIT_SOURCE="$2"
2222
2223# Skip for merges, squashes, or when using a commit template
2224if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2225 [ "$COMMIT_SOURCE" = "squash" ]; then
2226 exit 0
2227fi
2228
2229commit_msg=$(cat "$commit_file")
2230
2231needs_co_author=true
2232needs_change_id=true
2233
2234# Check if commit message already has Co-Authored-By trailer
2235if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2236 needs_co_author=false
2237fi
2238
2239# Check if commit message already has Change-ID trailer
2240if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2241 needs_change_id=false
2242fi
2243
2244# Only modify if at least one trailer needs to be added
2245if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002246 # Ensure there's a proper blank line before trailers
2247 if [ -s "$commit_file" ]; then
2248 # Check if file ends with newline by reading last character
2249 last_char=$(tail -c 1 "$commit_file")
2250
2251 if [ "$last_char" != "" ]; then
2252 # File doesn't end with newline - add two newlines (complete line + blank line)
2253 echo "" >> "$commit_file"
2254 echo "" >> "$commit_file"
2255 else
2256 # File ends with newline - check if we already have a blank line
2257 last_line=$(tail -1 "$commit_file")
2258 if [ -n "$last_line" ]; then
2259 # Last line has content - add one newline for blank line
2260 echo "" >> "$commit_file"
2261 fi
2262 # If last line is empty, we already have a blank line - don't add anything
2263 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002264 fi
2265
2266 # Add trailers if needed
2267 if [ "$needs_co_author" = true ]; then
2268 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2269 fi
2270
2271 if [ "$needs_change_id" = true ]; then
2272 change_id=$(openssl rand -hex 8)
2273 echo "Change-ID: s${change_id}k" >> "$commit_file"
2274 fi
2275fi
2276`
2277
2278 // Update or create the post-commit hook
2279 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2280 if err != nil {
2281 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2282 }
2283
2284 // Update or create the prepare-commit-msg hook
2285 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2286 if err != nil {
2287 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2288 }
2289
2290 return nil
2291}
2292
2293// updateOrCreateHook creates a new hook file or updates an existing one
2294// by appending the new content if it doesn't already contain it.
2295func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2296 // Check if the hook already exists
2297 buf, err := os.ReadFile(hookPath)
2298 if os.IsNotExist(err) {
2299 // Hook doesn't exist, create it
2300 err = os.WriteFile(hookPath, []byte(content), 0o755)
2301 if err != nil {
2302 return fmt.Errorf("failed to create hook: %w", err)
2303 }
2304 return nil
2305 }
2306 if err != nil {
2307 return fmt.Errorf("error reading existing hook: %w", err)
2308 }
2309
2310 // Hook exists, check if our content is already in it by looking for a distinctive line
2311 code := string(buf)
2312 if strings.Contains(code, distinctiveLine) {
2313 // Already contains our content, nothing to do
2314 return nil
2315 }
2316
2317 // Append our content to the existing hook
2318 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2319 if err != nil {
2320 return fmt.Errorf("failed to open hook for appending: %w", err)
2321 }
2322 defer f.Close()
2323
2324 // Ensure there's a newline at the end of the existing content if needed
2325 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2326 _, err = f.WriteString("\n")
2327 if err != nil {
2328 return fmt.Errorf("failed to add newline to hook: %w", err)
2329 }
2330 }
2331
2332 // Add a separator before our content
2333 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2334 if err != nil {
2335 return fmt.Errorf("failed to append to hook: %w", err)
2336 }
2337
2338 return nil
2339}