blob: 4a27ab082aefad6be06554fac162ac05118b0546 [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
Sean McCullough138ec242025-06-02 22:42:06 +0000146 // GetPortMonitor returns the port monitor instance for accessing port events
147 GetPortMonitor() *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700148}
149
150type CodingAgentMessageType string
151
152const (
153 UserMessageType CodingAgentMessageType = "user"
154 AgentMessageType CodingAgentMessageType = "agent"
155 ErrorMessageType CodingAgentMessageType = "error"
156 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
157 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700158 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
159 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
160 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700161
162 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
163)
164
165type AgentMessage struct {
166 Type CodingAgentMessageType `json:"type"`
167 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
168 EndOfTurn bool `json:"end_of_turn"`
169
170 Content string `json:"content"`
171 ToolName string `json:"tool_name,omitempty"`
172 ToolInput string `json:"input,omitempty"`
173 ToolResult string `json:"tool_result,omitempty"`
174 ToolError bool `json:"tool_error,omitempty"`
175 ToolCallId string `json:"tool_call_id,omitempty"`
176
177 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
178 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
179
Sean McCulloughd9f13372025-04-21 15:08:49 -0700180 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
181 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
182
Earl Lee2e463fb2025-04-17 11:22:22 -0700183 // Commits is a list of git commits for a commit message
184 Commits []*GitCommit `json:"commits,omitempty"`
185
186 Timestamp time.Time `json:"timestamp"`
187 ConversationID string `json:"conversation_id"`
188 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700189 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700190
191 // Message timing information
192 StartTime *time.Time `json:"start_time,omitempty"`
193 EndTime *time.Time `json:"end_time,omitempty"`
194 Elapsed *time.Duration `json:"elapsed,omitempty"`
195
196 // Turn duration - the time taken for a complete agent turn
197 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
198
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000199 // HideOutput indicates that this message should not be rendered in the UI.
200 // This is useful for subconversations that generate output that shouldn't be shown to the user.
201 HideOutput bool `json:"hide_output,omitempty"`
202
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700203 // TodoContent contains the agent's todo file content when it has changed
204 TodoContent *string `json:"todo_content,omitempty"`
205
Earl Lee2e463fb2025-04-17 11:22:22 -0700206 Idx int `json:"idx"`
207}
208
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000209// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700210func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700211 if convo == nil {
212 m.ConversationID = ""
213 m.ParentConversationID = nil
214 return
215 }
216 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000217 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700218 if convo.Parent != nil {
219 m.ParentConversationID = &convo.Parent.ID
220 }
221}
222
Earl Lee2e463fb2025-04-17 11:22:22 -0700223// GitCommit represents a single git commit for a commit message
224type GitCommit struct {
225 Hash string `json:"hash"` // Full commit hash
226 Subject string `json:"subject"` // Commit subject line
227 Body string `json:"body"` // Full commit message body
228 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
229}
230
231// ToolCall represents a single tool call within an agent message
232type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700233 Name string `json:"name"`
234 Input string `json:"input"`
235 ToolCallId string `json:"tool_call_id"`
236 ResultMessage *AgentMessage `json:"result_message,omitempty"`
237 Args string `json:"args,omitempty"`
238 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700239}
240
241func (a *AgentMessage) Attr() slog.Attr {
242 var attrs []any = []any{
243 slog.String("type", string(a.Type)),
244 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700245 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700246 if a.EndOfTurn {
247 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
248 }
249 if a.Content != "" {
250 attrs = append(attrs, slog.String("content", a.Content))
251 }
252 if a.ToolName != "" {
253 attrs = append(attrs, slog.String("tool_name", a.ToolName))
254 }
255 if a.ToolInput != "" {
256 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
257 }
258 if a.Elapsed != nil {
259 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
260 }
261 if a.TurnDuration != nil {
262 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
263 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700264 if len(a.ToolResult) > 0 {
265 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700266 }
267 if a.ToolError {
268 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
269 }
270 if len(a.ToolCalls) > 0 {
271 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
272 for i, tc := range a.ToolCalls {
273 toolCallAttrs = append(toolCallAttrs, slog.Group(
274 fmt.Sprintf("tool_call_%d", i),
275 slog.String("name", tc.Name),
276 slog.String("input", tc.Input),
277 ))
278 }
279 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
280 }
281 if a.ConversationID != "" {
282 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
283 }
284 if a.ParentConversationID != nil {
285 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
286 }
287 if a.Usage != nil && !a.Usage.IsZero() {
288 attrs = append(attrs, a.Usage.Attr())
289 }
290 // TODO: timestamp, convo ids, idx?
291 return slog.Group("agent_message", attrs...)
292}
293
294func errorMessage(err error) AgentMessage {
295 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
296 if os.Getenv(("DEBUG")) == "1" {
297 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
298 }
299
300 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
301}
302
303func budgetMessage(err error) AgentMessage {
304 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
305}
306
307// ConvoInterface defines the interface for conversation interactions
308type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700309 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700310 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700311 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700312 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700313 SendMessage(message llm.Message) (*llm.Response, error)
314 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700315 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000316 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700317 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700318 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700319 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700320}
321
Philip Zeyligerf2872992025-05-22 10:35:28 -0700322// AgentGitState holds the state necessary for pushing to a remote git repo
323// when HEAD changes. If gitRemoteAddr is set, then we push to sketch/
324// any time we notice we need to.
325type AgentGitState struct {
326 mu sync.Mutex // protects following
327 lastHEAD string // hash of the last HEAD that was pushed to the host
328 gitRemoteAddr string // HTTP URL of the host git repo
329 seenCommits map[string]bool // Track git commits we've already seen (by hash)
330 branchName string
331}
332
333func (ags *AgentGitState) SetBranchName(branchName string) {
334 ags.mu.Lock()
335 defer ags.mu.Unlock()
336 ags.branchName = branchName
337}
338
339func (ags *AgentGitState) BranchName() string {
340 ags.mu.Lock()
341 defer ags.mu.Unlock()
342 return ags.branchName
343}
344
Earl Lee2e463fb2025-04-17 11:22:22 -0700345type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700346 convo ConvoInterface
347 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700348 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700349 workingDir string
350 repoRoot string // workingDir may be a subdir of repoRoot
351 url string
352 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000353 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700354 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000355 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700356 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700357 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700358 title string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000359 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700360 // State machine to track agent state
361 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000362 // Outside information
363 outsideHostname string
364 outsideOS string
365 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000366 // URL of the git remote 'origin' if it exists
367 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700368
369 // Time when the current turn started (reset at the beginning of InnerLoop)
370 startOfTurn time.Time
371
372 // Inbox - for messages from the user to the agent.
373 // sent on by UserMessage
374 // . e.g. when user types into the chat textarea
375 // read from by GatherMessages
376 inbox chan string
377
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000378 // protects cancelTurn
379 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700380 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000381 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700382
383 // protects following
384 mu sync.Mutex
385
386 // Stores all messages for this agent
387 history []AgentMessage
388
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700389 // Iterators add themselves here when they're ready to be notified of new messages.
390 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700391
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000392 // Track outstanding LLM call IDs
393 outstandingLLMCalls map[string]struct{}
394
395 // Track outstanding tool calls by ID with their names
396 outstandingToolCalls map[string]string
Sean McCullough364f7412025-06-02 00:55:44 +0000397
398 // Port monitoring
399 portMonitor *PortMonitor
Philip Zeyligerb5739402025-06-02 07:04:34 -0700400
401 // End session feedback
402 endFeedback *EndFeedback
Earl Lee2e463fb2025-04-17 11:22:22 -0700403}
404
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700405// NewIterator implements CodingAgent.
406func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
407 a.mu.Lock()
408 defer a.mu.Unlock()
409
410 return &MessageIteratorImpl{
411 agent: a,
412 ctx: ctx,
413 nextMessageIdx: nextMessageIdx,
414 ch: make(chan *AgentMessage, 100),
415 }
416}
417
418type MessageIteratorImpl struct {
419 agent *Agent
420 ctx context.Context
421 nextMessageIdx int
422 ch chan *AgentMessage
423 subscribed bool
424}
425
426func (m *MessageIteratorImpl) Close() {
427 m.agent.mu.Lock()
428 defer m.agent.mu.Unlock()
429 // Delete ourselves from the subscribers list
430 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
431 return x == m.ch
432 })
433 close(m.ch)
434}
435
436func (m *MessageIteratorImpl) Next() *AgentMessage {
437 // We avoid subscription at creation to let ourselves catch up to "current state"
438 // before subscribing.
439 if !m.subscribed {
440 m.agent.mu.Lock()
441 if m.nextMessageIdx < len(m.agent.history) {
442 msg := &m.agent.history[m.nextMessageIdx]
443 m.nextMessageIdx++
444 m.agent.mu.Unlock()
445 return msg
446 }
447 // The next message doesn't exist yet, so let's subscribe
448 m.agent.subscribers = append(m.agent.subscribers, m.ch)
449 m.subscribed = true
450 m.agent.mu.Unlock()
451 }
452
453 for {
454 select {
455 case <-m.ctx.Done():
456 m.agent.mu.Lock()
457 // Delete ourselves from the subscribers list
458 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
459 return x == m.ch
460 })
461 m.subscribed = false
462 m.agent.mu.Unlock()
463 return nil
464 case msg, ok := <-m.ch:
465 if !ok {
466 // Close may have been called
467 return nil
468 }
469 if msg.Idx == m.nextMessageIdx {
470 m.nextMessageIdx++
471 return msg
472 }
473 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
474 panic("out of order message")
475 }
476 }
477}
478
Sean McCulloughd9d45812025-04-30 16:53:41 -0700479// Assert that Agent satisfies the CodingAgent interface.
480var _ CodingAgent = &Agent{}
481
482// StateName implements CodingAgent.
483func (a *Agent) CurrentStateName() string {
484 if a.stateMachine == nil {
485 return ""
486 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000487 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700488}
489
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700490// CurrentTodoContent returns the current todo list data as JSON.
491// It returns an empty string if no todos exist.
492func (a *Agent) CurrentTodoContent() string {
493 todoPath := claudetool.TodoFilePath(a.config.SessionID)
494 content, err := os.ReadFile(todoPath)
495 if err != nil {
496 return ""
497 }
498 return string(content)
499}
500
Philip Zeyligerb5739402025-06-02 07:04:34 -0700501// SetEndFeedback sets the end session feedback
502func (a *Agent) SetEndFeedback(feedback *EndFeedback) {
503 a.mu.Lock()
504 defer a.mu.Unlock()
505 a.endFeedback = feedback
506}
507
508// GetEndFeedback gets the end session feedback
509func (a *Agent) GetEndFeedback() *EndFeedback {
510 a.mu.Lock()
511 defer a.mu.Unlock()
512 return a.endFeedback
513}
514
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700515// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
516func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
517 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.
518
519IMPORTANT: 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.
520
521Please create a detailed summary that includes:
522
5231. **User's Request**: What did the user originally ask me to do? What was their goal?
524
5252. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
526
5273. **Key Technical Decisions**: What important technical choices were made during our work and why?
528
5294. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
530
5315. **Next Steps**: What still needs to be done to complete the user's request?
532
5336. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
534
535Focus 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.
536
537Reply with ONLY the summary content - no meta-commentary about creating the summary.`
538
539 userMessage := llm.UserStringMessage(msg)
540 // Use a subconversation with history to get the summary
541 // TODO: We don't have any tools here, so we should have enough tokens
542 // to capture a summary, but we may need to modify the history (e.g., remove
543 // TODO data) to save on some tokens.
544 convo := a.convo.SubConvoWithHistory()
545
546 // Modify the system prompt to provide context about the original task
547 originalSystemPrompt := convo.SystemPrompt
548 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.
549
550Your 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.
551
552Original context: You are working in a coding environment with full access to development tools.`)
553
554 resp, err := convo.SendMessage(userMessage)
555 if err != nil {
556 a.pushToOutbox(ctx, errorMessage(err))
557 return "", err
558 }
559 textContent := collectTextContent(resp)
560
561 // Restore original system prompt (though this subconvo will be discarded)
562 convo.SystemPrompt = originalSystemPrompt
563
564 return textContent, nil
565}
566
567// CompactConversation compacts the current conversation by generating a summary
568// and restarting the conversation with that summary as the initial context
569func (a *Agent) CompactConversation(ctx context.Context) error {
570 summary, err := a.generateConversationSummary(ctx)
571 if err != nil {
572 return fmt.Errorf("failed to generate conversation summary: %w", err)
573 }
574
575 a.mu.Lock()
576
577 // Get usage information before resetting conversation
578 lastUsage := a.convo.LastUsage()
579 contextWindow := a.config.Service.TokenContextWindow()
580 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
581
582 // Reset conversation state but keep all other state (git, working dir, etc.)
583 a.firstMessageIndex = len(a.history)
584 a.convo = a.initConvo()
585
586 a.mu.Unlock()
587
588 // Create informative compaction message with token details
589 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
590 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
591 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
592
593 a.pushToOutbox(ctx, AgentMessage{
594 Type: CompactMessageType,
595 Content: compactionMsg,
596 })
597
598 a.pushToOutbox(ctx, AgentMessage{
599 Type: UserMessageType,
600 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),
601 })
602 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)
603
604 return nil
605}
606
Earl Lee2e463fb2025-04-17 11:22:22 -0700607func (a *Agent) URL() string { return a.url }
608
609// Title returns the current title of the conversation.
610// If no title has been set, returns an empty string.
611func (a *Agent) Title() string {
612 a.mu.Lock()
613 defer a.mu.Unlock()
614 return a.title
615}
616
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000617// BranchName returns the git branch name for the conversation.
618func (a *Agent) BranchName() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700619 return a.gitState.BranchName()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000620}
621
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000622// OutstandingLLMCallCount returns the number of outstanding LLM calls.
623func (a *Agent) OutstandingLLMCallCount() int {
624 a.mu.Lock()
625 defer a.mu.Unlock()
626 return len(a.outstandingLLMCalls)
627}
628
629// OutstandingToolCalls returns the names of outstanding tool calls.
630func (a *Agent) OutstandingToolCalls() []string {
631 a.mu.Lock()
632 defer a.mu.Unlock()
633
634 tools := make([]string, 0, len(a.outstandingToolCalls))
635 for _, toolName := range a.outstandingToolCalls {
636 tools = append(tools, toolName)
637 }
638 return tools
639}
640
Earl Lee2e463fb2025-04-17 11:22:22 -0700641// OS returns the operating system of the client.
642func (a *Agent) OS() string {
643 return a.config.ClientGOOS
644}
645
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000646func (a *Agent) SessionID() string {
647 return a.config.SessionID
648}
649
Philip Zeyliger18532b22025-04-23 21:11:46 +0000650// OutsideOS returns the operating system of the outside system.
651func (a *Agent) OutsideOS() string {
652 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000653}
654
Philip Zeyliger18532b22025-04-23 21:11:46 +0000655// OutsideHostname returns the hostname of the outside system.
656func (a *Agent) OutsideHostname() string {
657 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000658}
659
Philip Zeyliger18532b22025-04-23 21:11:46 +0000660// OutsideWorkingDir returns the working directory on the outside system.
661func (a *Agent) OutsideWorkingDir() string {
662 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000663}
664
665// GitOrigin returns the URL of the git remote 'origin' if it exists.
666func (a *Agent) GitOrigin() string {
667 return a.gitOrigin
668}
669
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000670func (a *Agent) OpenBrowser(url string) {
671 if !a.IsInContainer() {
672 browser.Open(url)
673 return
674 }
675 // We're in Docker, need to send a request to the Git server
676 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700677 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000678 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700679 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000680 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700681 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000682 return
683 }
684 defer resp.Body.Close()
685 if resp.StatusCode == http.StatusOK {
686 return
687 }
688 body, _ := io.ReadAll(resp.Body)
689 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
690}
691
Sean McCullough96b60dd2025-04-30 09:49:10 -0700692// CurrentState returns the current state of the agent's state machine.
693func (a *Agent) CurrentState() State {
694 return a.stateMachine.CurrentState()
695}
696
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700697func (a *Agent) IsInContainer() bool {
698 return a.config.InDocker
699}
700
701func (a *Agent) FirstMessageIndex() int {
702 a.mu.Lock()
703 defer a.mu.Unlock()
704 return a.firstMessageIndex
705}
706
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000707// SetTitle sets the title of the conversation.
708func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700709 a.mu.Lock()
710 defer a.mu.Unlock()
711 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000712}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700713
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000714// SetBranch sets the branch name of the conversation.
715func (a *Agent) SetBranch(branchName string) {
716 a.mu.Lock()
717 defer a.mu.Unlock()
Philip Zeyligerf2872992025-05-22 10:35:28 -0700718 a.gitState.SetBranchName(branchName)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000719 convo, ok := a.convo.(*conversation.Convo)
720 if ok {
721 convo.ExtraData["branch"] = branchName
722 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700723}
724
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000725// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700726func (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 +0000727 // Track the tool call
728 a.mu.Lock()
729 a.outstandingToolCalls[id] = toolName
730 a.mu.Unlock()
731}
732
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700733// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
734// If there's only one element in the array and it's a text type, it returns that text directly.
735// It also processes nested ToolResult arrays recursively.
736func contentToString(contents []llm.Content) string {
737 if len(contents) == 0 {
738 return ""
739 }
740
741 // If there's only one element and it's a text type, return it directly
742 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
743 return contents[0].Text
744 }
745
746 // Otherwise, concatenate all text content
747 var result strings.Builder
748 for _, content := range contents {
749 if content.Type == llm.ContentTypeText {
750 result.WriteString(content.Text)
751 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
752 // Recursively process nested tool results
753 result.WriteString(contentToString(content.ToolResult))
754 }
755 }
756
757 return result.String()
758}
759
Earl Lee2e463fb2025-04-17 11:22:22 -0700760// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700761func (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 +0000762 // Remove the tool call from outstanding calls
763 a.mu.Lock()
764 delete(a.outstandingToolCalls, toolID)
765 a.mu.Unlock()
766
Earl Lee2e463fb2025-04-17 11:22:22 -0700767 m := AgentMessage{
768 Type: ToolUseMessageType,
769 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700770 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700771 ToolError: content.ToolError,
772 ToolName: toolName,
773 ToolInput: string(toolInput),
774 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700775 StartTime: content.ToolUseStartTime,
776 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700777 }
778
779 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700780 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
781 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700782 m.Elapsed = &elapsed
783 }
784
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700785 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700786 a.pushToOutbox(ctx, m)
787}
788
789// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700790func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000791 a.mu.Lock()
792 defer a.mu.Unlock()
793 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700794 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
795}
796
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700797// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700798// that need to be displayed (as well as tool calls that we send along when
799// they're done). (It would be reasonable to also mention tool calls when they're
800// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700801func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000802 // Remove the LLM call from outstanding calls
803 a.mu.Lock()
804 delete(a.outstandingLLMCalls, id)
805 a.mu.Unlock()
806
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700807 if resp == nil {
808 // LLM API call failed
809 m := AgentMessage{
810 Type: ErrorMessageType,
811 Content: "API call failed, type 'continue' to try again",
812 }
813 m.SetConvo(convo)
814 a.pushToOutbox(ctx, m)
815 return
816 }
817
Earl Lee2e463fb2025-04-17 11:22:22 -0700818 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700819 if convo.Parent == nil { // subconvos never end the turn
820 switch resp.StopReason {
821 case llm.StopReasonToolUse:
822 // Check whether any of the tool calls are for tools that should end the turn
823 ToolSearch:
824 for _, part := range resp.Content {
825 if part.Type != llm.ContentTypeToolUse {
826 continue
827 }
Sean McCullough021557a2025-05-05 23:20:53 +0000828 // Find the tool by name
829 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700830 if tool.Name == part.ToolName {
831 endOfTurn = tool.EndsTurn
832 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000833 }
834 }
Sean McCullough021557a2025-05-05 23:20:53 +0000835 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700836 default:
837 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000838 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700839 }
840 m := AgentMessage{
841 Type: AgentMessageType,
842 Content: collectTextContent(resp),
843 EndOfTurn: endOfTurn,
844 Usage: &resp.Usage,
845 StartTime: resp.StartTime,
846 EndTime: resp.EndTime,
847 }
848
849 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700850 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700851 var toolCalls []ToolCall
852 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700853 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700854 toolCalls = append(toolCalls, ToolCall{
855 Name: part.ToolName,
856 Input: string(part.ToolInput),
857 ToolCallId: part.ID,
858 })
859 }
860 }
861 m.ToolCalls = toolCalls
862 }
863
864 // Calculate the elapsed time if both start and end times are set
865 if resp.StartTime != nil && resp.EndTime != nil {
866 elapsed := resp.EndTime.Sub(*resp.StartTime)
867 m.Elapsed = &elapsed
868 }
869
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700870 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700871 a.pushToOutbox(ctx, m)
872}
873
874// WorkingDir implements CodingAgent.
875func (a *Agent) WorkingDir() string {
876 return a.workingDir
877}
878
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000879// RepoRoot returns the git repository root directory.
880func (a *Agent) RepoRoot() string {
881 return a.repoRoot
882}
883
Earl Lee2e463fb2025-04-17 11:22:22 -0700884// MessageCount implements CodingAgent.
885func (a *Agent) MessageCount() int {
886 a.mu.Lock()
887 defer a.mu.Unlock()
888 return len(a.history)
889}
890
891// Messages implements CodingAgent.
892func (a *Agent) Messages(start int, end int) []AgentMessage {
893 a.mu.Lock()
894 defer a.mu.Unlock()
895 return slices.Clone(a.history[start:end])
896}
897
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700898// ShouldCompact checks if the conversation should be compacted based on token usage
899func (a *Agent) ShouldCompact() bool {
900 // Get the threshold from environment variable, default to 0.94 (94%)
901 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
902 // and a little bit of buffer.)
903 thresholdRatio := 0.94
904 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
905 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
906 thresholdRatio = parsed
907 }
908 }
909
910 // Get the most recent usage to check current context size
911 lastUsage := a.convo.LastUsage()
912
913 if lastUsage.InputTokens == 0 {
914 // No API calls made yet
915 return false
916 }
917
918 // Calculate the current context size from the last API call
919 // This includes all tokens that were part of the input context:
920 // - Input tokens (user messages, system prompt, conversation history)
921 // - Cache read tokens (cached parts of the context)
922 // - Cache creation tokens (new parts being cached)
923 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
924
925 // Get the service's token context window
926 service := a.config.Service
927 contextWindow := service.TokenContextWindow()
928
929 // Calculate threshold
930 threshold := uint64(float64(contextWindow) * thresholdRatio)
931
932 // Check if we've exceeded the threshold
933 return currentContextSize >= threshold
934}
935
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700936func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700937 return a.originalBudget
938}
939
940// AgentConfig contains configuration for creating a new Agent.
941type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +0000942 Context context.Context
943 Service llm.Service
944 Budget conversation.Budget
945 GitUsername string
946 GitEmail string
947 SessionID string
948 ClientGOOS string
949 ClientGOARCH string
950 InDocker bool
951 OneShot bool
952 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000953 // Outside information
954 OutsideHostname string
955 OutsideOS string
956 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700957
958 // Outtie's HTTP to, e.g., open a browser
959 OutsideHTTP string
960 // Outtie's Git server
961 GitRemoteAddr string
962 // Commit to checkout from Outtie
963 Commit string
Earl Lee2e463fb2025-04-17 11:22:22 -0700964}
965
966// NewAgent creates a new Agent.
967// It is not usable until Init() is called.
968func NewAgent(config AgentConfig) *Agent {
969 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -0700970 config: config,
971 ready: make(chan struct{}),
972 inbox: make(chan string, 100),
973 subscribers: make([]chan *AgentMessage, 0),
974 startedAt: time.Now(),
975 originalBudget: config.Budget,
976 gitState: AgentGitState{
977 seenCommits: make(map[string]bool),
978 gitRemoteAddr: config.GitRemoteAddr,
979 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000980 outsideHostname: config.OutsideHostname,
981 outsideOS: config.OutsideOS,
982 outsideWorkingDir: config.OutsideWorkingDir,
983 outstandingLLMCalls: make(map[string]struct{}),
984 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700985 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700986 workingDir: config.WorkingDir,
987 outsideHTTP: config.OutsideHTTP,
Sean McCullough364f7412025-06-02 00:55:44 +0000988 portMonitor: NewPortMonitor(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700989 }
990 return agent
991}
992
993type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700994 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -0700995
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700996 InDocker bool
997 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -0700998}
999
1000func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001001 if a.convo != nil {
1002 return fmt.Errorf("Agent.Init: already initialized")
1003 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001004 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001005 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001006
Philip Zeyligerf2872992025-05-22 10:35:28 -07001007 // If a remote git addr was specified, we configure the remote
1008 if a.gitState.gitRemoteAddr != "" {
1009 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
1010 cmd := exec.CommandContext(ctx, "git", "remote", "add", "sketch-host", a.gitState.gitRemoteAddr)
1011 cmd.Dir = a.workingDir
1012 if out, err := cmd.CombinedOutput(); err != nil {
1013 return fmt.Errorf("git remote add: %s: %v", out, err)
1014 }
1015 // sketch-host is a git repo hosted by "outtie sketch". When it notices a 'git fetch',
1016 // it runs "git fetch" underneath the covers to get its latest commits. By configuring
1017 // an additional remote.sketch-host.fetch, we make "origin/main" on innie sketch look like
1018 // origin/main on outtie sketch, which should make it easier to rebase.
1019 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.sketch-host.fetch",
1020 "+refs/heads/feature/*:refs/remotes/origin/feature/*")
1021 cmd.Dir = a.workingDir
1022 if out, err := cmd.CombinedOutput(); err != nil {
1023 return fmt.Errorf("git config --add: %s: %v", out, err)
1024 }
1025 }
1026
1027 // If a commit was specified, we fetch and reset to it.
1028 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001029 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1030
Earl Lee2e463fb2025-04-17 11:22:22 -07001031 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001032 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001033 if out, err := cmd.CombinedOutput(); err != nil {
1034 return fmt.Errorf("git stash: %s: %v", out, err)
1035 }
Josh Bleecher Snyder76ccdfd2025-05-01 17:14:18 +00001036 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "sketch-host")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001037 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001038 if out, err := cmd.CombinedOutput(); err != nil {
1039 return fmt.Errorf("git fetch: %s: %w", out, err)
1040 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001041 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1042 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001043 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1044 // Remove git hooks if they exist and retry
1045 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001046 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001047 if _, statErr := os.Stat(hookPath); statErr == nil {
1048 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1049 slog.String("error", err.Error()),
1050 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001051 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001052 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1053 }
1054
1055 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001056 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1057 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001058 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001059 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 +01001060 }
1061 } else {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001062 return fmt.Errorf("git checkout %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001063 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001064 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001065 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001066
1067 if ini.HostAddr != "" {
1068 a.url = "http://" + ini.HostAddr
1069 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001070
1071 if !ini.NoGit {
1072 repoRoot, err := repoRoot(ctx, a.workingDir)
1073 if err != nil {
1074 return fmt.Errorf("repoRoot: %w", err)
1075 }
1076 a.repoRoot = repoRoot
1077
Earl Lee2e463fb2025-04-17 11:22:22 -07001078 if err != nil {
1079 return fmt.Errorf("resolveRef: %w", err)
1080 }
Philip Zeyliger49edc922025-05-14 09:45:45 -07001081
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001082 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001083 if err := setupGitHooks(a.repoRoot); err != nil {
1084 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1085 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001086 }
1087
Philip Zeyliger49edc922025-05-14 09:45:45 -07001088 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1089 cmd.Dir = repoRoot
1090 if out, err := cmd.CombinedOutput(); err != nil {
1091 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1092 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001093
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001094 slog.Info("running codebase analysis")
1095 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1096 if err != nil {
1097 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001098 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001099 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001100
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001101 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001102 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001103 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001104 }
1105 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001106
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001107 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
Earl Lee2e463fb2025-04-17 11:22:22 -07001108 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001109 a.gitState.lastHEAD = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001110 a.convo = a.initConvo()
1111 close(a.ready)
1112 return nil
1113}
1114
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001115//go:embed agent_system_prompt.txt
1116var agentSystemPrompt string
1117
Earl Lee2e463fb2025-04-17 11:22:22 -07001118// initConvo initializes the conversation.
1119// It must not be called until all agent fields are initialized,
1120// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001121func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001122 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001123 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -07001124 convo.PromptCaching = true
1125 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001126 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001127 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001128
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001129 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1130 bashPermissionCheck := func(command string) error {
1131 // Check if branch name is set
1132 a.mu.Lock()
Philip Zeyligerf2872992025-05-22 10:35:28 -07001133 branchSet := a.gitState.BranchName() != ""
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001134 a.mu.Unlock()
1135
1136 // If branch is set, all commands are allowed
1137 if branchSet {
1138 return nil
1139 }
1140
1141 // If branch is not set, check if this is a git commit command
1142 willCommit, err := bashkit.WillRunGitCommit(command)
1143 if err != nil {
1144 // If there's an error checking, we should allow the command to proceed
1145 return nil
1146 }
1147
1148 // If it's a git commit and branch is not set, return an error
1149 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001150 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001151 }
1152
1153 return nil
1154 }
1155
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001156 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001157
Earl Lee2e463fb2025-04-17 11:22:22 -07001158 // Register all tools with the conversation
1159 // When adding, removing, or modifying tools here, double-check that the termui tool display
1160 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001161
1162 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001163 _, supportsScreenshots := a.config.Service.(*ant.Service)
1164 var bTools []*llm.Tool
1165 var browserCleanup func()
1166
1167 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1168 // Add cleanup function to context cancel
1169 go func() {
1170 <-a.config.Context.Done()
1171 browserCleanup()
1172 }()
1173 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001174
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001175 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001176 bashTool, claudetool.Keyword, claudetool.Patch,
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001177 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001178 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001179 }
1180
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001181 // One-shot mode is non-interactive, multiple choice requires human response
1182 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001183 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001184 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001185
1186 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -07001187 convo.Listener = a
1188 return convo
1189}
1190
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001191var multipleChoiceTool = &llm.Tool{
1192 Name: "multiplechoice",
1193 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.",
1194 EndsTurn: true,
1195 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001196 "type": "object",
1197 "description": "The question and a list of answers you would expect the user to choose from.",
1198 "properties": {
1199 "question": {
1200 "type": "string",
1201 "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?'"
1202 },
1203 "responseOptions": {
1204 "type": "array",
1205 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1206 "items": {
1207 "type": "object",
1208 "properties": {
1209 "caption": {
1210 "type": "string",
1211 "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'"
1212 },
1213 "responseText": {
1214 "type": "string",
1215 "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'"
1216 }
1217 },
1218 "required": ["caption", "responseText"]
1219 }
1220 }
1221 },
1222 "required": ["question", "responseOptions"]
1223}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001224 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1225 // The Run logic for "multiplechoice" tool is a no-op on the server.
1226 // The UI will present a list of options for the user to select from,
1227 // and that's it as far as "executing" the tool_use goes.
1228 // When the user *does* select one of the presented options, that
1229 // responseText gets sent as a chat message on behalf of the user.
1230 return llm.TextContent("end your turn and wait for the user to respond"), nil
1231 },
Sean McCullough485afc62025-04-28 14:28:39 -07001232}
1233
1234type MultipleChoiceOption struct {
1235 Caption string `json:"caption"`
1236 ResponseText string `json:"responseText"`
1237}
1238
1239type MultipleChoiceParams struct {
1240 Question string `json:"question"`
1241 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1242}
1243
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001244// branchExists reports whether branchName exists, either locally or in well-known remotes.
1245func branchExists(dir, branchName string) bool {
1246 refs := []string{
1247 "refs/heads/",
1248 "refs/remotes/origin/",
1249 "refs/remotes/sketch-host/",
1250 }
1251 for _, ref := range refs {
1252 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1253 cmd.Dir = dir
1254 if cmd.Run() == nil { // exit code 0 means branch exists
1255 return true
1256 }
1257 }
1258 return false
1259}
1260
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001261func (a *Agent) titleTool() *llm.Tool {
1262 description := `Sets the conversation title.`
1263 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001264 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001265 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001266 InputSchema: json.RawMessage(`{
1267 "type": "object",
1268 "properties": {
1269 "title": {
1270 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001271 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001272 }
1273 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001274 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001275}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001276 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001277 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001278 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001279 }
1280 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001281 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001282 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001283
1284 // We don't allow changing the title once set to be consistent with the previous behavior
1285 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001286 t := a.Title()
1287 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001288 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001289 }
1290
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001291 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001292 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001293 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001294
1295 a.SetTitle(params.Title)
1296 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001297 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001298 },
1299 }
1300 return titleTool
1301}
1302
1303func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001304 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 +00001305 preCommit := &llm.Tool{
1306 Name: "precommit",
1307 Description: description,
1308 InputSchema: json.RawMessage(`{
1309 "type": "object",
1310 "properties": {
1311 "branch_name": {
1312 "type": "string",
1313 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1314 }
1315 },
1316 "required": ["branch_name"]
1317}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001318 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001319 var params struct {
1320 BranchName string `json:"branch_name"`
1321 }
1322 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001323 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001324 }
1325
1326 b := a.BranchName()
1327 if b != "" {
Josh Bleecher Snyder44d1f1a2025-05-12 19:18:32 -07001328 return nil, fmt.Errorf("branch already set to %s; do not create a new branch", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001329 }
1330
1331 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001332 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001333 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001334 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001335 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001336 }
1337 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001338 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001339 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001340 }
1341
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001342 a.SetBranch(branchName)
Josh Bleecher Snyderf7bebdd2025-05-14 15:22:24 -07001343 response := fmt.Sprintf("switched to branch sketch/%q - DO NOT change branches unless explicitly requested", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001344
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001345 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1346 if err != nil {
1347 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1348 }
1349 if len(styleHint) > 0 {
1350 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001351 }
1352
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001353 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001354 },
1355 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001356 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001357}
1358
1359func (a *Agent) Ready() <-chan struct{} {
1360 return a.ready
1361}
1362
1363func (a *Agent) UserMessage(ctx context.Context, msg string) {
1364 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1365 a.inbox <- msg
1366}
1367
Earl Lee2e463fb2025-04-17 11:22:22 -07001368func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1369 return a.convo.CancelToolUse(toolUseID, cause)
1370}
1371
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001372func (a *Agent) CancelTurn(cause error) {
1373 a.cancelTurnMu.Lock()
1374 defer a.cancelTurnMu.Unlock()
1375 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001376 // Force state transition to cancelled state
1377 ctx := a.config.Context
1378 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001379 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001380 }
1381}
1382
1383func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001384 // Start port monitoring when the agent loop begins
1385 // Only monitor ports when running in a container
1386 if a.IsInContainer() {
1387 a.portMonitor.Start(ctxOuter)
1388 }
1389
Earl Lee2e463fb2025-04-17 11:22:22 -07001390 for {
1391 select {
1392 case <-ctxOuter.Done():
1393 return
1394 default:
1395 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001396 a.cancelTurnMu.Lock()
1397 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001398 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001399 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001400 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001401 a.cancelTurn = cancel
1402 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001403 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1404 if err != nil {
1405 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1406 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001407 cancel(nil)
1408 }
1409 }
1410}
1411
1412func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1413 if m.Timestamp.IsZero() {
1414 m.Timestamp = time.Now()
1415 }
1416
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001417 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1418 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1419 m.Content = m.ToolResult
1420 }
1421
Earl Lee2e463fb2025-04-17 11:22:22 -07001422 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1423 if m.EndOfTurn && m.Type == AgentMessageType {
1424 turnDuration := time.Since(a.startOfTurn)
1425 m.TurnDuration = &turnDuration
1426 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1427 }
1428
Earl Lee2e463fb2025-04-17 11:22:22 -07001429 a.mu.Lock()
1430 defer a.mu.Unlock()
1431 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001432 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001433 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001434
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001435 // Notify all subscribers
1436 for _, ch := range a.subscribers {
1437 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001438 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001439}
1440
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001441func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1442 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001443 if block {
1444 select {
1445 case <-ctx.Done():
1446 return m, ctx.Err()
1447 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001448 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001449 }
1450 }
1451 for {
1452 select {
1453 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001454 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001455 default:
1456 return m, nil
1457 }
1458 }
1459}
1460
Sean McCullough885a16a2025-04-30 02:49:25 +00001461// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001462func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001463 // Reset the start of turn time
1464 a.startOfTurn = time.Now()
1465
Sean McCullough96b60dd2025-04-30 09:49:10 -07001466 // Transition to waiting for user input state
1467 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1468
Sean McCullough885a16a2025-04-30 02:49:25 +00001469 // Process initial user message
1470 initialResp, err := a.processUserMessage(ctx)
1471 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001472 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001473 return err
1474 }
1475
1476 // Handle edge case where both initialResp and err are nil
1477 if initialResp == nil {
1478 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001479 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1480
Sean McCullough9f4b8082025-04-30 17:34:07 +00001481 a.pushToOutbox(ctx, errorMessage(err))
1482 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001483 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001484
Earl Lee2e463fb2025-04-17 11:22:22 -07001485 // We do this as we go, but let's also do it at the end of the turn
1486 defer func() {
1487 if _, err := a.handleGitCommits(ctx); err != nil {
1488 // Just log the error, don't stop execution
1489 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1490 }
1491 }()
1492
Sean McCullougha1e0e492025-05-01 10:51:08 -07001493 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001494 resp := initialResp
1495 for {
1496 // Check if we are over budget
1497 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001498 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001499 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001500 }
1501
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001502 // Check if we should compact the conversation
1503 if a.ShouldCompact() {
1504 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1505 if err := a.CompactConversation(ctx); err != nil {
1506 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1507 return err
1508 }
1509 // After compaction, end this turn and start fresh
1510 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1511 return nil
1512 }
1513
Sean McCullough885a16a2025-04-30 02:49:25 +00001514 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001515 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001516 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001517 break
1518 }
1519
Sean McCullough96b60dd2025-04-30 09:49:10 -07001520 // Transition to tool use requested state
1521 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1522
Sean McCullough885a16a2025-04-30 02:49:25 +00001523 // Handle tool execution
1524 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1525 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001526 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001527 }
1528
Sean McCullougha1e0e492025-05-01 10:51:08 -07001529 if toolResp == nil {
1530 return fmt.Errorf("cannot continue conversation with a nil tool response")
1531 }
1532
Sean McCullough885a16a2025-04-30 02:49:25 +00001533 // Set the response for the next iteration
1534 resp = toolResp
1535 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001536
1537 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001538}
1539
1540// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001541func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001542 // Wait for at least one message from the user
1543 msgs, err := a.GatherMessages(ctx, true)
1544 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001545 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001546 return nil, err
1547 }
1548
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001549 userMessage := llm.Message{
1550 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001551 Content: msgs,
1552 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001553
Sean McCullough96b60dd2025-04-30 09:49:10 -07001554 // Transition to sending to LLM state
1555 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1556
Sean McCullough885a16a2025-04-30 02:49:25 +00001557 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001558 resp, err := a.convo.SendMessage(userMessage)
1559 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001560 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001561 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001562 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001563 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001564
Sean McCullough96b60dd2025-04-30 09:49:10 -07001565 // Transition to processing LLM response state
1566 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1567
Sean McCullough885a16a2025-04-30 02:49:25 +00001568 return resp, nil
1569}
1570
1571// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001572func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1573 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001574 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001575 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001576
Sean McCullough96b60dd2025-04-30 09:49:10 -07001577 // Transition to checking for cancellation state
1578 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1579
Sean McCullough885a16a2025-04-30 02:49:25 +00001580 // Check if the operation was cancelled by the user
1581 select {
1582 case <-ctx.Done():
1583 // Don't actually run any of the tools, but rather build a response
1584 // for each tool_use message letting the LLM know that user canceled it.
1585 var err error
1586 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001587 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001588 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001589 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001590 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001591 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001592 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001593 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001594 // Transition to running tool state
1595 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1596
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001597 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001598 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001599 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001600
1601 // Execute the tools
1602 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001603 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001604 if ctx.Err() != nil { // e.g. the user canceled the operation
1605 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001606 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001607 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001608 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001609 a.pushToOutbox(ctx, errorMessage(err))
1610 }
1611 }
1612
1613 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001614 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001615 autoqualityMessages := a.processGitChanges(ctx)
1616
1617 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001618 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001619 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001620 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001621 return false, nil
1622 }
1623
1624 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001625 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1626 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001627}
1628
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001629// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001630func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001631 // Check for git commits
1632 _, err := a.handleGitCommits(ctx)
1633 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001634 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001635 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001636 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001637 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001638}
1639
1640// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1641// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001642func (a *Agent) processGitChanges(ctx context.Context) []string {
1643 // Check for git commits after tool execution
1644 newCommits, err := a.handleGitCommits(ctx)
1645 if err != nil {
1646 // Just log the error, don't stop execution
1647 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1648 return nil
1649 }
1650
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001651 // Run mechanical checks if there was exactly one new commit.
1652 if len(newCommits) != 1 {
1653 return nil
1654 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001655 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001656 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1657 msg := a.codereview.RunMechanicalChecks(ctx)
1658 if msg != "" {
1659 a.pushToOutbox(ctx, AgentMessage{
1660 Type: AutoMessageType,
1661 Content: msg,
1662 Timestamp: time.Now(),
1663 })
1664 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001665 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001666
1667 return autoqualityMessages
1668}
1669
1670// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001671func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001672 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001673 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001674 msgs, err := a.GatherMessages(ctx, false)
1675 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001676 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001677 return false, nil
1678 }
1679
1680 // Inject any auto-generated messages from quality checks
1681 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001682 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001683 }
1684
1685 // Handle cancellation by appending a message about it
1686 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001687 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001688 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001689 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001690 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1691 } else if err := a.convo.OverBudget(); err != nil {
1692 // Handle budget issues by appending a message about it
1693 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 -07001694 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001695 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1696 }
1697
1698 // Combine tool results with user messages
1699 results = append(results, msgs...)
1700
1701 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001702 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001703 resp, err := a.convo.SendMessage(llm.Message{
1704 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001705 Content: results,
1706 })
1707 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001708 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001709 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1710 return true, nil // Return true to continue the conversation, but with no response
1711 }
1712
Sean McCullough96b60dd2025-04-30 09:49:10 -07001713 // Transition back to processing LLM response
1714 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1715
Sean McCullough885a16a2025-04-30 02:49:25 +00001716 if cancelled {
1717 return false, nil
1718 }
1719
1720 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001721}
1722
1723func (a *Agent) overBudget(ctx context.Context) error {
1724 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001725 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001726 m := budgetMessage(err)
1727 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001728 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001729 a.convo.ResetBudget(a.originalBudget)
1730 return err
1731 }
1732 return nil
1733}
1734
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001735func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001736 // Collect all text content
1737 var allText strings.Builder
1738 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001739 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001740 if allText.Len() > 0 {
1741 allText.WriteString("\n\n")
1742 }
1743 allText.WriteString(content.Text)
1744 }
1745 }
1746 return allText.String()
1747}
1748
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001749func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001750 a.mu.Lock()
1751 defer a.mu.Unlock()
1752 return a.convo.CumulativeUsage()
1753}
1754
Earl Lee2e463fb2025-04-17 11:22:22 -07001755// Diff returns a unified diff of changes made since the agent was instantiated.
1756func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001757 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001758 return "", fmt.Errorf("no initial commit reference available")
1759 }
1760
1761 // Find the repository root
1762 ctx := context.Background()
1763
1764 // If a specific commit hash is provided, show just that commit's changes
1765 if commit != nil && *commit != "" {
1766 // Validate that the commit looks like a valid git SHA
1767 if !isValidGitSHA(*commit) {
1768 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1769 }
1770
1771 // Get the diff for just this commit
1772 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1773 cmd.Dir = a.repoRoot
1774 output, err := cmd.CombinedOutput()
1775 if err != nil {
1776 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1777 }
1778 return string(output), nil
1779 }
1780
1781 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001782 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001783 cmd.Dir = a.repoRoot
1784 output, err := cmd.CombinedOutput()
1785 if err != nil {
1786 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1787 }
1788
1789 return string(output), nil
1790}
1791
Philip Zeyliger49edc922025-05-14 09:45:45 -07001792// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1793// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1794func (a *Agent) SketchGitBaseRef() string {
1795 if a.IsInContainer() {
1796 return "sketch-base"
1797 } else {
1798 return "sketch-base-" + a.SessionID()
1799 }
1800}
1801
1802// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1803func (a *Agent) SketchGitBase() string {
1804 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1805 cmd.Dir = a.repoRoot
1806 output, err := cmd.CombinedOutput()
1807 if err != nil {
1808 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1809 return "HEAD"
1810 }
1811 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001812}
1813
Pokey Rule7a113622025-05-12 10:58:45 +01001814// removeGitHooks removes the Git hooks directory from the repository
1815func removeGitHooks(_ context.Context, repoPath string) error {
1816 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1817
1818 // Check if hooks directory exists
1819 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1820 // Directory doesn't exist, nothing to do
1821 return nil
1822 }
1823
1824 // Remove the hooks directory
1825 err := os.RemoveAll(hooksDir)
1826 if err != nil {
1827 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1828 }
1829
1830 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001831 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001832 if err != nil {
1833 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1834 }
1835
1836 return nil
1837}
1838
Philip Zeyligerf2872992025-05-22 10:35:28 -07001839func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1840 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef())
1841 for _, msg := range msgs {
1842 a.pushToOutbox(ctx, msg)
1843 }
1844 return commits, error
1845}
1846
Earl Lee2e463fb2025-04-17 11:22:22 -07001847// handleGitCommits() highlights new commits to the user. When running
1848// under docker, new HEADs are pushed to a branch according to the title.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001849func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string) ([]AgentMessage, []*GitCommit, error) {
1850 ags.mu.Lock()
1851 defer ags.mu.Unlock()
1852
1853 msgs := []AgentMessage{}
1854 if repoRoot == "" {
1855 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001856 }
1857
Philip Zeyligerf2872992025-05-22 10:35:28 -07001858 head, err := resolveRef(ctx, repoRoot, "HEAD")
Earl Lee2e463fb2025-04-17 11:22:22 -07001859 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001860 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001861 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001862 if head == ags.lastHEAD {
1863 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001864 }
1865 defer func() {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001866 ags.lastHEAD = head
Earl Lee2e463fb2025-04-17 11:22:22 -07001867 }()
1868
1869 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1870 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1871 // to the last 100 commits.
1872 var commits []*GitCommit
1873
1874 // Get commits since the initial commit
1875 // Format: <hash>\0<subject>\0<body>\0
1876 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1877 // Limit to 100 commits to avoid overwhelming the user
Philip Zeyligerf2872992025-05-22 10:35:28 -07001878 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, head)
1879 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001880 output, err := cmd.Output()
1881 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001882 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001883 }
1884
1885 // Parse git log output and filter out already seen commits
1886 parsedCommits := parseGitLog(string(output))
1887
1888 var headCommit *GitCommit
1889
1890 // Filter out commits we've already seen
1891 for _, commit := range parsedCommits {
1892 if commit.Hash == head {
1893 headCommit = &commit
1894 }
1895
1896 // Skip if we've seen this commit before. If our head has changed, always include that.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001897 if ags.seenCommits[commit.Hash] && commit.Hash != head {
Earl Lee2e463fb2025-04-17 11:22:22 -07001898 continue
1899 }
1900
1901 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001902 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001903
1904 // Add to our list of new commits
1905 commits = append(commits, &commit)
1906 }
1907
Philip Zeyligerf2872992025-05-22 10:35:28 -07001908 if ags.gitRemoteAddr != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001909 if headCommit == nil {
1910 // I think this can only happen if we have a bug or if there's a race.
1911 headCommit = &GitCommit{}
1912 headCommit.Hash = head
1913 headCommit.Subject = "unknown"
1914 commits = append(commits, headCommit)
1915 }
1916
Philip Zeyligerf2872992025-05-22 10:35:28 -07001917 originalBranch := cmp.Or(ags.branchName, "sketch/"+sessionID)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001918 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001919
1920 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1921 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1922 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001923
1924 // Try up to 10 times with different branch names if the branch is checked out on the remote
1925 var out []byte
1926 var err error
1927 for retries := range 10 {
1928 if retries > 0 {
1929 // Add a numeric suffix to the branch name
1930 branch = fmt.Sprintf("%s%d", originalBranch, retries)
1931 }
1932
Philip Zeyligerf2872992025-05-22 10:35:28 -07001933 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1934 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001935 out, err = cmd.CombinedOutput()
1936
1937 if err == nil {
1938 // Success! Break out of the retry loop
1939 break
1940 }
1941
1942 // Check if this is the "refusing to update checked out branch" error
1943 if !strings.Contains(string(out), "refusing to update checked out branch") {
1944 // This is a different error, so don't retry
1945 break
1946 }
1947
1948 // If we're on the last retry, we'll report the error
1949 if retries == 9 {
1950 break
1951 }
1952 }
1953
1954 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001955 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001956 } else {
1957 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001958 // Update the agent's branch name if we ended up using a different one
1959 if branch != originalBranch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001960 ags.branchName = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001961 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001962 }
1963 }
1964
1965 // If we found new commits, create a message
1966 if len(commits) > 0 {
1967 msg := AgentMessage{
1968 Type: CommitMessageType,
1969 Timestamp: time.Now(),
1970 Commits: commits,
1971 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001972 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001973 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001974 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001975}
1976
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001977func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001978 return strings.Map(func(r rune) rune {
1979 // lowercase
1980 if r >= 'A' && r <= 'Z' {
1981 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001982 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001983 // replace spaces with dashes
1984 if r == ' ' {
1985 return '-'
1986 }
1987 // allow alphanumerics and dashes
1988 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1989 return r
1990 }
1991 return -1
1992 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001993}
1994
1995// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
1996// and returns an array of GitCommit structs.
1997func parseGitLog(output string) []GitCommit {
1998 var commits []GitCommit
1999
2000 // No output means no commits
2001 if len(output) == 0 {
2002 return commits
2003 }
2004
2005 // Split by NULL byte
2006 parts := strings.Split(output, "\x00")
2007
2008 // Process in triplets (hash, subject, body)
2009 for i := 0; i < len(parts); i++ {
2010 // Skip empty parts
2011 if parts[i] == "" {
2012 continue
2013 }
2014
2015 // This should be a hash
2016 hash := strings.TrimSpace(parts[i])
2017
2018 // Make sure we have at least a subject part available
2019 if i+1 >= len(parts) {
2020 break // No more parts available
2021 }
2022
2023 // Get the subject
2024 subject := strings.TrimSpace(parts[i+1])
2025
2026 // Get the body if available
2027 body := ""
2028 if i+2 < len(parts) {
2029 body = strings.TrimSpace(parts[i+2])
2030 }
2031
2032 // Skip to the next triplet
2033 i += 2
2034
2035 commits = append(commits, GitCommit{
2036 Hash: hash,
2037 Subject: subject,
2038 Body: body,
2039 })
2040 }
2041
2042 return commits
2043}
2044
2045func repoRoot(ctx context.Context, dir string) (string, error) {
2046 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2047 stderr := new(strings.Builder)
2048 cmd.Stderr = stderr
2049 cmd.Dir = dir
2050 out, err := cmd.Output()
2051 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002052 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002053 }
2054 return strings.TrimSpace(string(out)), nil
2055}
2056
2057func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2058 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2059 stderr := new(strings.Builder)
2060 cmd.Stderr = stderr
2061 cmd.Dir = dir
2062 out, err := cmd.Output()
2063 if err != nil {
2064 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2065 }
2066 // TODO: validate that out is valid hex
2067 return strings.TrimSpace(string(out)), nil
2068}
2069
2070// isValidGitSHA validates if a string looks like a valid git SHA hash.
2071// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2072func isValidGitSHA(sha string) bool {
2073 // Git SHA must be a hexadecimal string with at least 4 characters
2074 if len(sha) < 4 || len(sha) > 40 {
2075 return false
2076 }
2077
2078 // Check if the string only contains hexadecimal characters
2079 for _, char := range sha {
2080 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2081 return false
2082 }
2083 }
2084
2085 return true
2086}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002087
2088// getGitOrigin returns the URL of the git remote 'origin' if it exists
2089func getGitOrigin(ctx context.Context, dir string) string {
2090 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2091 cmd.Dir = dir
2092 stderr := new(strings.Builder)
2093 cmd.Stderr = stderr
2094 out, err := cmd.Output()
2095 if err != nil {
2096 return ""
2097 }
2098 return strings.TrimSpace(string(out))
2099}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002100
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002101// systemPromptData contains the data used to render the system prompt template
2102type systemPromptData struct {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002103 ClientGOOS string
2104 ClientGOARCH string
2105 WorkingDir string
2106 RepoRoot string
2107 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002108 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002109}
2110
2111// renderSystemPrompt renders the system prompt template.
2112func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002113 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002114 ClientGOOS: a.config.ClientGOOS,
2115 ClientGOARCH: a.config.ClientGOARCH,
2116 WorkingDir: a.workingDir,
2117 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002118 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002119 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002120 }
2121
2122 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2123 if err != nil {
2124 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2125 }
2126 buf := new(strings.Builder)
2127 err = tmpl.Execute(buf, data)
2128 if err != nil {
2129 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2130 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002131 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002132 return buf.String()
2133}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002134
2135// StateTransitionIterator provides an iterator over state transitions.
2136type StateTransitionIterator interface {
2137 // Next blocks until a new state transition is available or context is done.
2138 // Returns nil if the context is cancelled.
2139 Next() *StateTransition
2140 // Close removes the listener and cleans up resources.
2141 Close()
2142}
2143
2144// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2145type StateTransitionIteratorImpl struct {
2146 agent *Agent
2147 ctx context.Context
2148 ch chan StateTransition
2149 unsubscribe func()
2150}
2151
2152// Next blocks until a new state transition is available or the context is cancelled.
2153func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2154 select {
2155 case <-s.ctx.Done():
2156 return nil
2157 case transition, ok := <-s.ch:
2158 if !ok {
2159 return nil
2160 }
2161 transitionCopy := transition
2162 return &transitionCopy
2163 }
2164}
2165
2166// Close removes the listener and cleans up resources.
2167func (s *StateTransitionIteratorImpl) Close() {
2168 if s.unsubscribe != nil {
2169 s.unsubscribe()
2170 s.unsubscribe = nil
2171 }
2172}
2173
2174// NewStateTransitionIterator returns an iterator that receives state transitions.
2175func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2176 a.mu.Lock()
2177 defer a.mu.Unlock()
2178
2179 // Create channel to receive state transitions
2180 ch := make(chan StateTransition, 10)
2181
2182 // Add a listener to the state machine
2183 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2184
2185 return &StateTransitionIteratorImpl{
2186 agent: a,
2187 ctx: ctx,
2188 ch: ch,
2189 unsubscribe: unsubscribe,
2190 }
2191}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002192
2193// setupGitHooks creates or updates git hooks in the specified working directory.
2194func setupGitHooks(workingDir string) error {
2195 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2196
2197 _, err := os.Stat(hooksDir)
2198 if os.IsNotExist(err) {
2199 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2200 }
2201 if err != nil {
2202 return fmt.Errorf("error checking git hooks directory: %w", err)
2203 }
2204
2205 // Define the post-commit hook content
2206 postCommitHook := `#!/bin/bash
2207echo "<post_commit_hook>"
2208echo "Please review this commit message and fix it if it is incorrect."
2209echo "This hook only echos the commit message; it does not modify it."
2210echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2211echo "<last_commit_message>"
2212git log -1 --pretty=%B
2213echo "</last_commit_message>"
2214echo "</post_commit_hook>"
2215`
2216
2217 // Define the prepare-commit-msg hook content
2218 prepareCommitMsgHook := `#!/bin/bash
2219# Add Co-Authored-By and Change-ID trailers to commit messages
2220# Check if these trailers already exist before adding them
2221
2222commit_file="$1"
2223COMMIT_SOURCE="$2"
2224
2225# Skip for merges, squashes, or when using a commit template
2226if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2227 [ "$COMMIT_SOURCE" = "squash" ]; then
2228 exit 0
2229fi
2230
2231commit_msg=$(cat "$commit_file")
2232
2233needs_co_author=true
2234needs_change_id=true
2235
2236# Check if commit message already has Co-Authored-By trailer
2237if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2238 needs_co_author=false
2239fi
2240
2241# Check if commit message already has Change-ID trailer
2242if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2243 needs_change_id=false
2244fi
2245
2246# Only modify if at least one trailer needs to be added
2247if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002248 # Ensure there's a proper blank line before trailers
2249 if [ -s "$commit_file" ]; then
2250 # Check if file ends with newline by reading last character
2251 last_char=$(tail -c 1 "$commit_file")
2252
2253 if [ "$last_char" != "" ]; then
2254 # File doesn't end with newline - add two newlines (complete line + blank line)
2255 echo "" >> "$commit_file"
2256 echo "" >> "$commit_file"
2257 else
2258 # File ends with newline - check if we already have a blank line
2259 last_line=$(tail -1 "$commit_file")
2260 if [ -n "$last_line" ]; then
2261 # Last line has content - add one newline for blank line
2262 echo "" >> "$commit_file"
2263 fi
2264 # If last line is empty, we already have a blank line - don't add anything
2265 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002266 fi
2267
2268 # Add trailers if needed
2269 if [ "$needs_co_author" = true ]; then
2270 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2271 fi
2272
2273 if [ "$needs_change_id" = true ]; then
2274 change_id=$(openssl rand -hex 8)
2275 echo "Change-ID: s${change_id}k" >> "$commit_file"
2276 fi
2277fi
2278`
2279
2280 // Update or create the post-commit hook
2281 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2282 if err != nil {
2283 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2284 }
2285
2286 // Update or create the prepare-commit-msg hook
2287 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2288 if err != nil {
2289 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2290 }
2291
2292 return nil
2293}
2294
2295// updateOrCreateHook creates a new hook file or updates an existing one
2296// by appending the new content if it doesn't already contain it.
2297func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2298 // Check if the hook already exists
2299 buf, err := os.ReadFile(hookPath)
2300 if os.IsNotExist(err) {
2301 // Hook doesn't exist, create it
2302 err = os.WriteFile(hookPath, []byte(content), 0o755)
2303 if err != nil {
2304 return fmt.Errorf("failed to create hook: %w", err)
2305 }
2306 return nil
2307 }
2308 if err != nil {
2309 return fmt.Errorf("error reading existing hook: %w", err)
2310 }
2311
2312 // Hook exists, check if our content is already in it by looking for a distinctive line
2313 code := string(buf)
2314 if strings.Contains(code, distinctiveLine) {
2315 // Already contains our content, nothing to do
2316 return nil
2317 }
2318
2319 // Append our content to the existing hook
2320 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2321 if err != nil {
2322 return fmt.Errorf("failed to open hook for appending: %w", err)
2323 }
2324 defer f.Close()
2325
2326 // Ensure there's a newline at the end of the existing content if needed
2327 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2328 _, err = f.WriteString("\n")
2329 if err != nil {
2330 return fmt.Errorf("failed to add newline to hook: %w", err)
2331 }
2332 }
2333
2334 // Add a separator before our content
2335 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2336 if err != nil {
2337 return fmt.Errorf("failed to append to hook: %w", err)
2338 }
2339
2340 return nil
2341}
Sean McCullough138ec242025-06-02 22:42:06 +00002342
2343// GetPortMonitor returns the port monitor instance for accessing port events
2344func (a *Agent) GetPortMonitor() *PortMonitor {
2345 return a.portMonitor
2346}