blob: f5c6e0711d7aa8268b9c872ae850de5c7f2ab3a4 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package loop
2
3import (
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07004 "cmp"
Earl Lee2e463fb2025-04-17 11:22:22 -07005 "context"
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07006 _ "embed"
Earl Lee2e463fb2025-04-17 11:22:22 -07007 "encoding/json"
8 "fmt"
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +00009 "io"
Earl Lee2e463fb2025-04-17 11:22:22 -070010 "log/slog"
11 "net/http"
12 "os"
13 "os/exec"
Pokey Rule7a113622025-05-12 10:58:45 +010014 "path/filepath"
Philip Zeyliger59e1c162025-06-02 12:54:34 +000015 "regexp"
Earl Lee2e463fb2025-04-17 11:22:22 -070016 "runtime/debug"
17 "slices"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070018 "strconv"
Earl Lee2e463fb2025-04-17 11:22:22 -070019 "strings"
20 "sync"
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +000021 "text/template"
Earl Lee2e463fb2025-04-17 11:22:22 -070022 "time"
23
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000024 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070025 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000026 "sketch.dev/claudetool/bashkit"
Autoformatter4962f152025-05-06 17:24:20 +000027 "sketch.dev/claudetool/browse"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000028 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydera997be62025-05-07 22:52:46 +000029 "sketch.dev/claudetool/onstart"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070030 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070031 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070032 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070033)
34
35const (
36 userCancelMessage = "user requested agent to stop handling responses"
37)
38
Philip Zeyligerb7c58752025-05-01 10:10:17 -070039type MessageIterator interface {
40 // Next blocks until the next message is available. It may
41 // return nil if the underlying iterator context is done.
42 Next() *AgentMessage
43 Close()
44}
45
Earl Lee2e463fb2025-04-17 11:22:22 -070046type CodingAgent interface {
47 // Init initializes an agent inside a docker container.
48 Init(AgentInit) error
49
50 // Ready returns a channel closed after Init successfully called.
51 Ready() <-chan struct{}
52
53 // URL reports the HTTP URL of this agent.
54 URL() string
55
56 // UserMessage enqueues a message to the agent and returns immediately.
57 UserMessage(ctx context.Context, msg string)
58
Philip Zeyligerb7c58752025-05-01 10:10:17 -070059 // Returns an iterator that finishes when the context is done and
60 // starts with the given message index.
61 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070062
Philip Zeyligereab12de2025-05-14 02:35:53 +000063 // Returns an iterator that notifies of state transitions until the context is done.
64 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
65
Earl Lee2e463fb2025-04-17 11:22:22 -070066 // Loop begins the agent loop returns only when ctx is cancelled.
67 Loop(ctx context.Context)
68
Sean McCulloughedc88dc2025-04-30 02:55:01 +000069 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070070
71 CancelToolUse(toolUseID string, cause error) error
72
73 // Returns a subset of the agent's message history.
74 Messages(start int, end int) []AgentMessage
75
76 // Returns the current number of messages in the history
77 MessageCount() int
78
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070079 TotalUsage() conversation.CumulativeUsage
80 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070081
Earl Lee2e463fb2025-04-17 11:22:22 -070082 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000083 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070084
85 // Diff returns a unified diff of changes made since the agent was instantiated.
86 // If commit is non-nil, it shows the diff for just that specific commit.
87 Diff(commit *string) (string, error)
88
Philip Zeyliger49edc922025-05-14 09:45:45 -070089 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
90 // starts out as the commit where sketch started, but a user can move it if need
91 // be, for example in the case of a rebase. It is stored as a git tag.
92 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070093
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000094 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
95 // (Typically, this is "sketch-base")
96 SketchGitBaseRef() string
97
Earl Lee2e463fb2025-04-17 11:22:22 -070098 // Title returns the current title of the conversation.
99 Title() string
100
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000101 // BranchName returns the git branch name for the conversation.
102 BranchName() string
103
Earl Lee2e463fb2025-04-17 11:22:22 -0700104 // OS returns the operating system of the client.
105 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000106
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000107 // SessionID returns the unique session identifier.
108 SessionID() string
109
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000110 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700111 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000112
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000113 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
114 OutstandingLLMCallCount() int
115
116 // OutstandingToolCalls returns the names of outstanding tool calls.
117 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000118 OutsideOS() string
119 OutsideHostname() string
120 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000121 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000122 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
123 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700124
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700125 // IsInContainer returns true if the agent is running in a container
126 IsInContainer() bool
127 // FirstMessageIndex returns the index of the first message in the current conversation
128 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700129
130 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700131 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
132 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700133
134 // CompactConversation compacts the current conversation by generating a summary
135 // and restarting the conversation with that summary as the initial context
136 CompactConversation(ctx context.Context) error
Sean McCullough138ec242025-06-02 22:42:06 +0000137 // GetPortMonitor returns the port monitor instance for accessing port events
138 GetPortMonitor() *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700139}
140
141type CodingAgentMessageType string
142
143const (
144 UserMessageType CodingAgentMessageType = "user"
145 AgentMessageType CodingAgentMessageType = "agent"
146 ErrorMessageType CodingAgentMessageType = "error"
147 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
148 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700149 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
150 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
151 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700152
153 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
154)
155
156type AgentMessage struct {
157 Type CodingAgentMessageType `json:"type"`
158 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
159 EndOfTurn bool `json:"end_of_turn"`
160
161 Content string `json:"content"`
162 ToolName string `json:"tool_name,omitempty"`
163 ToolInput string `json:"input,omitempty"`
164 ToolResult string `json:"tool_result,omitempty"`
165 ToolError bool `json:"tool_error,omitempty"`
166 ToolCallId string `json:"tool_call_id,omitempty"`
167
168 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
169 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
170
Sean McCulloughd9f13372025-04-21 15:08:49 -0700171 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
172 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
173
Earl Lee2e463fb2025-04-17 11:22:22 -0700174 // Commits is a list of git commits for a commit message
175 Commits []*GitCommit `json:"commits,omitempty"`
176
177 Timestamp time.Time `json:"timestamp"`
178 ConversationID string `json:"conversation_id"`
179 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700180 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700181
182 // Message timing information
183 StartTime *time.Time `json:"start_time,omitempty"`
184 EndTime *time.Time `json:"end_time,omitempty"`
185 Elapsed *time.Duration `json:"elapsed,omitempty"`
186
187 // Turn duration - the time taken for a complete agent turn
188 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
189
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000190 // HideOutput indicates that this message should not be rendered in the UI.
191 // This is useful for subconversations that generate output that shouldn't be shown to the user.
192 HideOutput bool `json:"hide_output,omitempty"`
193
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700194 // TodoContent contains the agent's todo file content when it has changed
195 TodoContent *string `json:"todo_content,omitempty"`
196
Earl Lee2e463fb2025-04-17 11:22:22 -0700197 Idx int `json:"idx"`
198}
199
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000200// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700201func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700202 if convo == nil {
203 m.ConversationID = ""
204 m.ParentConversationID = nil
205 return
206 }
207 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000208 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700209 if convo.Parent != nil {
210 m.ParentConversationID = &convo.Parent.ID
211 }
212}
213
Earl Lee2e463fb2025-04-17 11:22:22 -0700214// GitCommit represents a single git commit for a commit message
215type GitCommit struct {
216 Hash string `json:"hash"` // Full commit hash
217 Subject string `json:"subject"` // Commit subject line
218 Body string `json:"body"` // Full commit message body
219 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
220}
221
222// ToolCall represents a single tool call within an agent message
223type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700224 Name string `json:"name"`
225 Input string `json:"input"`
226 ToolCallId string `json:"tool_call_id"`
227 ResultMessage *AgentMessage `json:"result_message,omitempty"`
228 Args string `json:"args,omitempty"`
229 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700230}
231
232func (a *AgentMessage) Attr() slog.Attr {
233 var attrs []any = []any{
234 slog.String("type", string(a.Type)),
235 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700236 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700237 if a.EndOfTurn {
238 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
239 }
240 if a.Content != "" {
241 attrs = append(attrs, slog.String("content", a.Content))
242 }
243 if a.ToolName != "" {
244 attrs = append(attrs, slog.String("tool_name", a.ToolName))
245 }
246 if a.ToolInput != "" {
247 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
248 }
249 if a.Elapsed != nil {
250 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
251 }
252 if a.TurnDuration != nil {
253 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
254 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700255 if len(a.ToolResult) > 0 {
256 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700257 }
258 if a.ToolError {
259 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
260 }
261 if len(a.ToolCalls) > 0 {
262 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
263 for i, tc := range a.ToolCalls {
264 toolCallAttrs = append(toolCallAttrs, slog.Group(
265 fmt.Sprintf("tool_call_%d", i),
266 slog.String("name", tc.Name),
267 slog.String("input", tc.Input),
268 ))
269 }
270 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
271 }
272 if a.ConversationID != "" {
273 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
274 }
275 if a.ParentConversationID != nil {
276 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
277 }
278 if a.Usage != nil && !a.Usage.IsZero() {
279 attrs = append(attrs, a.Usage.Attr())
280 }
281 // TODO: timestamp, convo ids, idx?
282 return slog.Group("agent_message", attrs...)
283}
284
285func errorMessage(err error) AgentMessage {
286 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
287 if os.Getenv(("DEBUG")) == "1" {
288 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
289 }
290
291 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
292}
293
294func budgetMessage(err error) AgentMessage {
295 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
296}
297
298// ConvoInterface defines the interface for conversation interactions
299type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700300 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700301 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700302 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700303 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700304 SendMessage(message llm.Message) (*llm.Response, error)
305 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700306 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000307 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700308 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700309 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700310 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700311}
312
Philip Zeyligerf2872992025-05-22 10:35:28 -0700313// AgentGitState holds the state necessary for pushing to a remote git repo
314// when HEAD changes. If gitRemoteAddr is set, then we push to sketch/
315// any time we notice we need to.
316type AgentGitState struct {
317 mu sync.Mutex // protects following
318 lastHEAD string // hash of the last HEAD that was pushed to the host
319 gitRemoteAddr string // HTTP URL of the host git repo
320 seenCommits map[string]bool // Track git commits we've already seen (by hash)
321 branchName string
322}
323
324func (ags *AgentGitState) SetBranchName(branchName string) {
325 ags.mu.Lock()
326 defer ags.mu.Unlock()
327 ags.branchName = branchName
328}
329
330func (ags *AgentGitState) BranchName() string {
331 ags.mu.Lock()
332 defer ags.mu.Unlock()
333 return ags.branchName
334}
335
Earl Lee2e463fb2025-04-17 11:22:22 -0700336type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700337 convo ConvoInterface
338 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700339 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700340 workingDir string
341 repoRoot string // workingDir may be a subdir of repoRoot
342 url string
343 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000344 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700345 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000346 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700347 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700348 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700349 title string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000350 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700351 // State machine to track agent state
352 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000353 // Outside information
354 outsideHostname string
355 outsideOS string
356 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000357 // URL of the git remote 'origin' if it exists
358 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700359
360 // Time when the current turn started (reset at the beginning of InnerLoop)
361 startOfTurn time.Time
362
363 // Inbox - for messages from the user to the agent.
364 // sent on by UserMessage
365 // . e.g. when user types into the chat textarea
366 // read from by GatherMessages
367 inbox chan string
368
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000369 // protects cancelTurn
370 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700371 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000372 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700373
374 // protects following
375 mu sync.Mutex
376
377 // Stores all messages for this agent
378 history []AgentMessage
379
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700380 // Iterators add themselves here when they're ready to be notified of new messages.
381 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700382
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000383 // Track outstanding LLM call IDs
384 outstandingLLMCalls map[string]struct{}
385
386 // Track outstanding tool calls by ID with their names
387 outstandingToolCalls map[string]string
Sean McCullough364f7412025-06-02 00:55:44 +0000388
389 // Port monitoring
390 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700391}
392
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700393// NewIterator implements CodingAgent.
394func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
395 a.mu.Lock()
396 defer a.mu.Unlock()
397
398 return &MessageIteratorImpl{
399 agent: a,
400 ctx: ctx,
401 nextMessageIdx: nextMessageIdx,
402 ch: make(chan *AgentMessage, 100),
403 }
404}
405
406type MessageIteratorImpl struct {
407 agent *Agent
408 ctx context.Context
409 nextMessageIdx int
410 ch chan *AgentMessage
411 subscribed bool
412}
413
414func (m *MessageIteratorImpl) Close() {
415 m.agent.mu.Lock()
416 defer m.agent.mu.Unlock()
417 // Delete ourselves from the subscribers list
418 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
419 return x == m.ch
420 })
421 close(m.ch)
422}
423
424func (m *MessageIteratorImpl) Next() *AgentMessage {
425 // We avoid subscription at creation to let ourselves catch up to "current state"
426 // before subscribing.
427 if !m.subscribed {
428 m.agent.mu.Lock()
429 if m.nextMessageIdx < len(m.agent.history) {
430 msg := &m.agent.history[m.nextMessageIdx]
431 m.nextMessageIdx++
432 m.agent.mu.Unlock()
433 return msg
434 }
435 // The next message doesn't exist yet, so let's subscribe
436 m.agent.subscribers = append(m.agent.subscribers, m.ch)
437 m.subscribed = true
438 m.agent.mu.Unlock()
439 }
440
441 for {
442 select {
443 case <-m.ctx.Done():
444 m.agent.mu.Lock()
445 // Delete ourselves from the subscribers list
446 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
447 return x == m.ch
448 })
449 m.subscribed = false
450 m.agent.mu.Unlock()
451 return nil
452 case msg, ok := <-m.ch:
453 if !ok {
454 // Close may have been called
455 return nil
456 }
457 if msg.Idx == m.nextMessageIdx {
458 m.nextMessageIdx++
459 return msg
460 }
461 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
462 panic("out of order message")
463 }
464 }
465}
466
Sean McCulloughd9d45812025-04-30 16:53:41 -0700467// Assert that Agent satisfies the CodingAgent interface.
468var _ CodingAgent = &Agent{}
469
470// StateName implements CodingAgent.
471func (a *Agent) CurrentStateName() string {
472 if a.stateMachine == nil {
473 return ""
474 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000475 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700476}
477
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700478// CurrentTodoContent returns the current todo list data as JSON.
479// It returns an empty string if no todos exist.
480func (a *Agent) CurrentTodoContent() string {
481 todoPath := claudetool.TodoFilePath(a.config.SessionID)
482 content, err := os.ReadFile(todoPath)
483 if err != nil {
484 return ""
485 }
486 return string(content)
487}
488
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700489// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
490func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
491 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.
492
493IMPORTANT: 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.
494
495Please create a detailed summary that includes:
496
4971. **User's Request**: What did the user originally ask me to do? What was their goal?
498
4992. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
500
5013. **Key Technical Decisions**: What important technical choices were made during our work and why?
502
5034. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
504
5055. **Next Steps**: What still needs to be done to complete the user's request?
506
5076. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
508
509Focus 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.
510
511Reply with ONLY the summary content - no meta-commentary about creating the summary.`
512
513 userMessage := llm.UserStringMessage(msg)
514 // Use a subconversation with history to get the summary
515 // TODO: We don't have any tools here, so we should have enough tokens
516 // to capture a summary, but we may need to modify the history (e.g., remove
517 // TODO data) to save on some tokens.
518 convo := a.convo.SubConvoWithHistory()
519
520 // Modify the system prompt to provide context about the original task
521 originalSystemPrompt := convo.SystemPrompt
522 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.
523
524Your 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.
525
526Original context: You are working in a coding environment with full access to development tools.`)
527
528 resp, err := convo.SendMessage(userMessage)
529 if err != nil {
530 a.pushToOutbox(ctx, errorMessage(err))
531 return "", err
532 }
533 textContent := collectTextContent(resp)
534
535 // Restore original system prompt (though this subconvo will be discarded)
536 convo.SystemPrompt = originalSystemPrompt
537
538 return textContent, nil
539}
540
541// CompactConversation compacts the current conversation by generating a summary
542// and restarting the conversation with that summary as the initial context
543func (a *Agent) CompactConversation(ctx context.Context) error {
544 summary, err := a.generateConversationSummary(ctx)
545 if err != nil {
546 return fmt.Errorf("failed to generate conversation summary: %w", err)
547 }
548
549 a.mu.Lock()
550
551 // Get usage information before resetting conversation
552 lastUsage := a.convo.LastUsage()
553 contextWindow := a.config.Service.TokenContextWindow()
554 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
555
556 // Reset conversation state but keep all other state (git, working dir, etc.)
557 a.firstMessageIndex = len(a.history)
558 a.convo = a.initConvo()
559
560 a.mu.Unlock()
561
562 // Create informative compaction message with token details
563 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
564 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
565 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
566
567 a.pushToOutbox(ctx, AgentMessage{
568 Type: CompactMessageType,
569 Content: compactionMsg,
570 })
571
572 a.pushToOutbox(ctx, AgentMessage{
573 Type: UserMessageType,
574 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),
575 })
576 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)
577
578 return nil
579}
580
Earl Lee2e463fb2025-04-17 11:22:22 -0700581func (a *Agent) URL() string { return a.url }
582
583// Title returns the current title of the conversation.
584// If no title has been set, returns an empty string.
585func (a *Agent) Title() string {
586 a.mu.Lock()
587 defer a.mu.Unlock()
588 return a.title
589}
590
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000591// BranchName returns the git branch name for the conversation.
592func (a *Agent) BranchName() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700593 return a.gitState.BranchName()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000594}
595
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000596// OutstandingLLMCallCount returns the number of outstanding LLM calls.
597func (a *Agent) OutstandingLLMCallCount() int {
598 a.mu.Lock()
599 defer a.mu.Unlock()
600 return len(a.outstandingLLMCalls)
601}
602
603// OutstandingToolCalls returns the names of outstanding tool calls.
604func (a *Agent) OutstandingToolCalls() []string {
605 a.mu.Lock()
606 defer a.mu.Unlock()
607
608 tools := make([]string, 0, len(a.outstandingToolCalls))
609 for _, toolName := range a.outstandingToolCalls {
610 tools = append(tools, toolName)
611 }
612 return tools
613}
614
Earl Lee2e463fb2025-04-17 11:22:22 -0700615// OS returns the operating system of the client.
616func (a *Agent) OS() string {
617 return a.config.ClientGOOS
618}
619
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000620func (a *Agent) SessionID() string {
621 return a.config.SessionID
622}
623
Philip Zeyliger18532b22025-04-23 21:11:46 +0000624// OutsideOS returns the operating system of the outside system.
625func (a *Agent) OutsideOS() string {
626 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000627}
628
Philip Zeyliger18532b22025-04-23 21:11:46 +0000629// OutsideHostname returns the hostname of the outside system.
630func (a *Agent) OutsideHostname() string {
631 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000632}
633
Philip Zeyliger18532b22025-04-23 21:11:46 +0000634// OutsideWorkingDir returns the working directory on the outside system.
635func (a *Agent) OutsideWorkingDir() string {
636 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000637}
638
639// GitOrigin returns the URL of the git remote 'origin' if it exists.
640func (a *Agent) GitOrigin() string {
641 return a.gitOrigin
642}
643
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000644func (a *Agent) OpenBrowser(url string) {
645 if !a.IsInContainer() {
646 browser.Open(url)
647 return
648 }
649 // We're in Docker, need to send a request to the Git server
650 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700651 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000652 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700653 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000654 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700655 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000656 return
657 }
658 defer resp.Body.Close()
659 if resp.StatusCode == http.StatusOK {
660 return
661 }
662 body, _ := io.ReadAll(resp.Body)
663 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
664}
665
Sean McCullough96b60dd2025-04-30 09:49:10 -0700666// CurrentState returns the current state of the agent's state machine.
667func (a *Agent) CurrentState() State {
668 return a.stateMachine.CurrentState()
669}
670
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700671func (a *Agent) IsInContainer() bool {
672 return a.config.InDocker
673}
674
675func (a *Agent) FirstMessageIndex() int {
676 a.mu.Lock()
677 defer a.mu.Unlock()
678 return a.firstMessageIndex
679}
680
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000681// SetTitle sets the title of the conversation.
682func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700683 a.mu.Lock()
684 defer a.mu.Unlock()
685 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000686}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700687
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000688// SetBranch sets the branch name of the conversation.
689func (a *Agent) SetBranch(branchName string) {
690 a.mu.Lock()
691 defer a.mu.Unlock()
Philip Zeyligerf2872992025-05-22 10:35:28 -0700692 a.gitState.SetBranchName(branchName)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000693 convo, ok := a.convo.(*conversation.Convo)
694 if ok {
695 convo.ExtraData["branch"] = branchName
696 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700697}
698
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000699// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700700func (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 +0000701 // Track the tool call
702 a.mu.Lock()
703 a.outstandingToolCalls[id] = toolName
704 a.mu.Unlock()
705}
706
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700707// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
708// If there's only one element in the array and it's a text type, it returns that text directly.
709// It also processes nested ToolResult arrays recursively.
710func contentToString(contents []llm.Content) string {
711 if len(contents) == 0 {
712 return ""
713 }
714
715 // If there's only one element and it's a text type, return it directly
716 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
717 return contents[0].Text
718 }
719
720 // Otherwise, concatenate all text content
721 var result strings.Builder
722 for _, content := range contents {
723 if content.Type == llm.ContentTypeText {
724 result.WriteString(content.Text)
725 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
726 // Recursively process nested tool results
727 result.WriteString(contentToString(content.ToolResult))
728 }
729 }
730
731 return result.String()
732}
733
Earl Lee2e463fb2025-04-17 11:22:22 -0700734// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700735func (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 +0000736 // Remove the tool call from outstanding calls
737 a.mu.Lock()
738 delete(a.outstandingToolCalls, toolID)
739 a.mu.Unlock()
740
Earl Lee2e463fb2025-04-17 11:22:22 -0700741 m := AgentMessage{
742 Type: ToolUseMessageType,
743 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700744 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700745 ToolError: content.ToolError,
746 ToolName: toolName,
747 ToolInput: string(toolInput),
748 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700749 StartTime: content.ToolUseStartTime,
750 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700751 }
752
753 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700754 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
755 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700756 m.Elapsed = &elapsed
757 }
758
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700759 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700760 a.pushToOutbox(ctx, m)
761}
762
763// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700764func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000765 a.mu.Lock()
766 defer a.mu.Unlock()
767 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700768 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
769}
770
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700771// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700772// that need to be displayed (as well as tool calls that we send along when
773// they're done). (It would be reasonable to also mention tool calls when they're
774// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700775func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000776 // Remove the LLM call from outstanding calls
777 a.mu.Lock()
778 delete(a.outstandingLLMCalls, id)
779 a.mu.Unlock()
780
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700781 if resp == nil {
782 // LLM API call failed
783 m := AgentMessage{
784 Type: ErrorMessageType,
785 Content: "API call failed, type 'continue' to try again",
786 }
787 m.SetConvo(convo)
788 a.pushToOutbox(ctx, m)
789 return
790 }
791
Earl Lee2e463fb2025-04-17 11:22:22 -0700792 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700793 if convo.Parent == nil { // subconvos never end the turn
794 switch resp.StopReason {
795 case llm.StopReasonToolUse:
796 // Check whether any of the tool calls are for tools that should end the turn
797 ToolSearch:
798 for _, part := range resp.Content {
799 if part.Type != llm.ContentTypeToolUse {
800 continue
801 }
Sean McCullough021557a2025-05-05 23:20:53 +0000802 // Find the tool by name
803 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700804 if tool.Name == part.ToolName {
805 endOfTurn = tool.EndsTurn
806 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000807 }
808 }
Sean McCullough021557a2025-05-05 23:20:53 +0000809 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700810 default:
811 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000812 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700813 }
814 m := AgentMessage{
815 Type: AgentMessageType,
816 Content: collectTextContent(resp),
817 EndOfTurn: endOfTurn,
818 Usage: &resp.Usage,
819 StartTime: resp.StartTime,
820 EndTime: resp.EndTime,
821 }
822
823 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700824 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700825 var toolCalls []ToolCall
826 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700827 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700828 toolCalls = append(toolCalls, ToolCall{
829 Name: part.ToolName,
830 Input: string(part.ToolInput),
831 ToolCallId: part.ID,
832 })
833 }
834 }
835 m.ToolCalls = toolCalls
836 }
837
838 // Calculate the elapsed time if both start and end times are set
839 if resp.StartTime != nil && resp.EndTime != nil {
840 elapsed := resp.EndTime.Sub(*resp.StartTime)
841 m.Elapsed = &elapsed
842 }
843
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700844 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700845 a.pushToOutbox(ctx, m)
846}
847
848// WorkingDir implements CodingAgent.
849func (a *Agent) WorkingDir() string {
850 return a.workingDir
851}
852
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000853// RepoRoot returns the git repository root directory.
854func (a *Agent) RepoRoot() string {
855 return a.repoRoot
856}
857
Earl Lee2e463fb2025-04-17 11:22:22 -0700858// MessageCount implements CodingAgent.
859func (a *Agent) MessageCount() int {
860 a.mu.Lock()
861 defer a.mu.Unlock()
862 return len(a.history)
863}
864
865// Messages implements CodingAgent.
866func (a *Agent) Messages(start int, end int) []AgentMessage {
867 a.mu.Lock()
868 defer a.mu.Unlock()
869 return slices.Clone(a.history[start:end])
870}
871
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700872// ShouldCompact checks if the conversation should be compacted based on token usage
873func (a *Agent) ShouldCompact() bool {
874 // Get the threshold from environment variable, default to 0.94 (94%)
875 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
876 // and a little bit of buffer.)
877 thresholdRatio := 0.94
878 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
879 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
880 thresholdRatio = parsed
881 }
882 }
883
884 // Get the most recent usage to check current context size
885 lastUsage := a.convo.LastUsage()
886
887 if lastUsage.InputTokens == 0 {
888 // No API calls made yet
889 return false
890 }
891
892 // Calculate the current context size from the last API call
893 // This includes all tokens that were part of the input context:
894 // - Input tokens (user messages, system prompt, conversation history)
895 // - Cache read tokens (cached parts of the context)
896 // - Cache creation tokens (new parts being cached)
897 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
898
899 // Get the service's token context window
900 service := a.config.Service
901 contextWindow := service.TokenContextWindow()
902
903 // Calculate threshold
904 threshold := uint64(float64(contextWindow) * thresholdRatio)
905
906 // Check if we've exceeded the threshold
907 return currentContextSize >= threshold
908}
909
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700910func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700911 return a.originalBudget
912}
913
914// AgentConfig contains configuration for creating a new Agent.
915type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +0000916 Context context.Context
917 Service llm.Service
918 Budget conversation.Budget
919 GitUsername string
920 GitEmail string
921 SessionID string
922 ClientGOOS string
923 ClientGOARCH string
924 InDocker bool
925 OneShot bool
926 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000927 // Outside information
928 OutsideHostname string
929 OutsideOS string
930 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700931
932 // Outtie's HTTP to, e.g., open a browser
933 OutsideHTTP string
934 // Outtie's Git server
935 GitRemoteAddr string
936 // Commit to checkout from Outtie
937 Commit string
Earl Lee2e463fb2025-04-17 11:22:22 -0700938}
939
940// NewAgent creates a new Agent.
941// It is not usable until Init() is called.
942func NewAgent(config AgentConfig) *Agent {
943 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -0700944 config: config,
945 ready: make(chan struct{}),
946 inbox: make(chan string, 100),
947 subscribers: make([]chan *AgentMessage, 0),
948 startedAt: time.Now(),
949 originalBudget: config.Budget,
950 gitState: AgentGitState{
951 seenCommits: make(map[string]bool),
952 gitRemoteAddr: config.GitRemoteAddr,
953 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000954 outsideHostname: config.OutsideHostname,
955 outsideOS: config.OutsideOS,
956 outsideWorkingDir: config.OutsideWorkingDir,
957 outstandingLLMCalls: make(map[string]struct{}),
958 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700959 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700960 workingDir: config.WorkingDir,
961 outsideHTTP: config.OutsideHTTP,
Sean McCullough364f7412025-06-02 00:55:44 +0000962 portMonitor: NewPortMonitor(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700963 }
964 return agent
965}
966
967type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700968 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -0700969
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700970 InDocker bool
971 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -0700972}
973
974func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -0700975 if a.convo != nil {
976 return fmt.Errorf("Agent.Init: already initialized")
977 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700978 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -0700979 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700980
Philip Zeyliger2f0eb692025-06-04 09:53:42 -0700981 if !ini.NoGit {
982 // Capture the original origin before we potentially replace it below
983 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
984 }
985
Philip Zeyliger222bf412025-06-04 16:42:58 +0000986 // If a remote git addr was specified, we configure the origin remote
Philip Zeyligerf2872992025-05-22 10:35:28 -0700987 if a.gitState.gitRemoteAddr != "" {
988 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
Philip Zeyliger222bf412025-06-04 16:42:58 +0000989
990 // Remove existing origin remote if it exists
991 cmd := exec.CommandContext(ctx, "git", "remote", "remove", "origin")
Philip Zeyligerf2872992025-05-22 10:35:28 -0700992 cmd.Dir = a.workingDir
993 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +0000994 // Ignore error if origin doesn't exist
995 slog.DebugContext(ctx, "git remote remove origin (ignoring if not exists)", slog.String("output", string(out)))
Philip Zeyligerf2872992025-05-22 10:35:28 -0700996 }
Philip Zeyliger222bf412025-06-04 16:42:58 +0000997
998 // Add the new remote as origin
999 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", a.gitState.gitRemoteAddr)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001000 cmd.Dir = a.workingDir
1001 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001002 return fmt.Errorf("git remote add origin: %s: %v", out, err)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001003 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001004
Philip Zeyligerf2872992025-05-22 10:35:28 -07001005 }
1006
1007 // If a commit was specified, we fetch and reset to it.
1008 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001009 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1010
Earl Lee2e463fb2025-04-17 11:22:22 -07001011 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001012 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001013 if out, err := cmd.CombinedOutput(); err != nil {
1014 return fmt.Errorf("git stash: %s: %v", out, err)
1015 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001016 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001017 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001018 if out, err := cmd.CombinedOutput(); err != nil {
1019 return fmt.Errorf("git fetch: %s: %w", out, err)
1020 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001021 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1022 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001023 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1024 // Remove git hooks if they exist and retry
1025 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001026 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001027 if _, statErr := os.Stat(hookPath); statErr == nil {
1028 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1029 slog.String("error", err.Error()),
1030 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001031 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001032 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1033 }
1034
1035 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001036 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1037 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001038 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001039 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 +01001040 }
1041 } else {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001042 return fmt.Errorf("git checkout %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001043 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001044 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001045 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001046
1047 if ini.HostAddr != "" {
1048 a.url = "http://" + ini.HostAddr
1049 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001050
1051 if !ini.NoGit {
1052 repoRoot, err := repoRoot(ctx, a.workingDir)
1053 if err != nil {
1054 return fmt.Errorf("repoRoot: %w", err)
1055 }
1056 a.repoRoot = repoRoot
1057
Earl Lee2e463fb2025-04-17 11:22:22 -07001058 if err != nil {
1059 return fmt.Errorf("resolveRef: %w", err)
1060 }
Philip Zeyliger49edc922025-05-14 09:45:45 -07001061
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001062 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001063 if err := setupGitHooks(a.repoRoot); err != nil {
1064 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1065 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001066 }
1067
Philip Zeyliger49edc922025-05-14 09:45:45 -07001068 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1069 cmd.Dir = repoRoot
1070 if out, err := cmd.CombinedOutput(); err != nil {
1071 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1072 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001073
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001074 slog.Info("running codebase analysis")
1075 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1076 if err != nil {
1077 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001078 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001079 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001080
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001081 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001082 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001083 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001084 }
1085 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001086
Earl Lee2e463fb2025-04-17 11:22:22 -07001087 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001088 a.gitState.lastHEAD = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001089 a.convo = a.initConvo()
1090 close(a.ready)
1091 return nil
1092}
1093
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001094//go:embed agent_system_prompt.txt
1095var agentSystemPrompt string
1096
Earl Lee2e463fb2025-04-17 11:22:22 -07001097// initConvo initializes the conversation.
1098// It must not be called until all agent fields are initialized,
1099// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001100func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001101 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001102 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -07001103 convo.PromptCaching = true
1104 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001105 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001106 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001107
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001108 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1109 bashPermissionCheck := func(command string) error {
1110 // Check if branch name is set
1111 a.mu.Lock()
Philip Zeyligerf2872992025-05-22 10:35:28 -07001112 branchSet := a.gitState.BranchName() != ""
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001113 a.mu.Unlock()
1114
1115 // If branch is set, all commands are allowed
1116 if branchSet {
1117 return nil
1118 }
1119
1120 // If branch is not set, check if this is a git commit command
1121 willCommit, err := bashkit.WillRunGitCommit(command)
1122 if err != nil {
1123 // If there's an error checking, we should allow the command to proceed
1124 return nil
1125 }
1126
1127 // If it's a git commit and branch is not set, return an error
1128 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001129 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001130 }
1131
1132 return nil
1133 }
1134
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001135 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001136
Earl Lee2e463fb2025-04-17 11:22:22 -07001137 // Register all tools with the conversation
1138 // When adding, removing, or modifying tools here, double-check that the termui tool display
1139 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001140
1141 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001142 _, supportsScreenshots := a.config.Service.(*ant.Service)
1143 var bTools []*llm.Tool
1144 var browserCleanup func()
1145
1146 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1147 // Add cleanup function to context cancel
1148 go func() {
1149 <-a.config.Context.Done()
1150 browserCleanup()
1151 }()
1152 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001153
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001154 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001155 bashTool, claudetool.Keyword, claudetool.Patch,
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001156 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001157 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001158 }
1159
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001160 // One-shot mode is non-interactive, multiple choice requires human response
1161 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001162 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001163 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001164
1165 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -07001166 convo.Listener = a
1167 return convo
1168}
1169
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001170var multipleChoiceTool = &llm.Tool{
1171 Name: "multiplechoice",
1172 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.",
1173 EndsTurn: true,
1174 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001175 "type": "object",
1176 "description": "The question and a list of answers you would expect the user to choose from.",
1177 "properties": {
1178 "question": {
1179 "type": "string",
1180 "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?'"
1181 },
1182 "responseOptions": {
1183 "type": "array",
1184 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1185 "items": {
1186 "type": "object",
1187 "properties": {
1188 "caption": {
1189 "type": "string",
1190 "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'"
1191 },
1192 "responseText": {
1193 "type": "string",
1194 "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'"
1195 }
1196 },
1197 "required": ["caption", "responseText"]
1198 }
1199 }
1200 },
1201 "required": ["question", "responseOptions"]
1202}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001203 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1204 // The Run logic for "multiplechoice" tool is a no-op on the server.
1205 // The UI will present a list of options for the user to select from,
1206 // and that's it as far as "executing" the tool_use goes.
1207 // When the user *does* select one of the presented options, that
1208 // responseText gets sent as a chat message on behalf of the user.
1209 return llm.TextContent("end your turn and wait for the user to respond"), nil
1210 },
Sean McCullough485afc62025-04-28 14:28:39 -07001211}
1212
1213type MultipleChoiceOption struct {
1214 Caption string `json:"caption"`
1215 ResponseText string `json:"responseText"`
1216}
1217
1218type MultipleChoiceParams struct {
1219 Question string `json:"question"`
1220 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1221}
1222
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001223// branchExists reports whether branchName exists, either locally or in well-known remotes.
1224func branchExists(dir, branchName string) bool {
1225 refs := []string{
1226 "refs/heads/",
1227 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001228 }
1229 for _, ref := range refs {
1230 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1231 cmd.Dir = dir
1232 if cmd.Run() == nil { // exit code 0 means branch exists
1233 return true
1234 }
1235 }
1236 return false
1237}
1238
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001239func (a *Agent) titleTool() *llm.Tool {
1240 description := `Sets the conversation title.`
1241 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001242 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001243 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001244 InputSchema: json.RawMessage(`{
1245 "type": "object",
1246 "properties": {
1247 "title": {
1248 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001249 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001250 }
1251 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001252 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001253}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001254 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001255 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001256 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001257 }
1258 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001259 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001260 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001261
1262 // We don't allow changing the title once set to be consistent with the previous behavior
1263 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001264 t := a.Title()
1265 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001266 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001267 }
1268
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001269 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001270 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001271 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001272
1273 a.SetTitle(params.Title)
1274 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001275 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001276 },
1277 }
1278 return titleTool
1279}
1280
1281func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001282 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 +00001283 preCommit := &llm.Tool{
1284 Name: "precommit",
1285 Description: description,
1286 InputSchema: json.RawMessage(`{
1287 "type": "object",
1288 "properties": {
1289 "branch_name": {
1290 "type": "string",
1291 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1292 }
1293 },
1294 "required": ["branch_name"]
1295}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001296 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001297 var params struct {
1298 BranchName string `json:"branch_name"`
1299 }
1300 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001301 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001302 }
1303
1304 b := a.BranchName()
1305 if b != "" {
Josh Bleecher Snyder44d1f1a2025-05-12 19:18:32 -07001306 return nil, fmt.Errorf("branch already set to %s; do not create a new branch", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001307 }
1308
1309 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001310 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001311 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001312 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001313 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001314 }
1315 branchName := "sketch/" + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001316 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001317 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001318 }
1319
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001320 a.SetBranch(branchName)
Josh Bleecher Snyderf7bebdd2025-05-14 15:22:24 -07001321 response := fmt.Sprintf("switched to branch sketch/%q - DO NOT change branches unless explicitly requested", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001322
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001323 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1324 if err != nil {
1325 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1326 }
1327 if len(styleHint) > 0 {
1328 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001329 }
1330
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001331 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001332 },
1333 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001334 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001335}
1336
1337func (a *Agent) Ready() <-chan struct{} {
1338 return a.ready
1339}
1340
1341func (a *Agent) UserMessage(ctx context.Context, msg string) {
1342 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1343 a.inbox <- msg
1344}
1345
Earl Lee2e463fb2025-04-17 11:22:22 -07001346func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1347 return a.convo.CancelToolUse(toolUseID, cause)
1348}
1349
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001350func (a *Agent) CancelTurn(cause error) {
1351 a.cancelTurnMu.Lock()
1352 defer a.cancelTurnMu.Unlock()
1353 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001354 // Force state transition to cancelled state
1355 ctx := a.config.Context
1356 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001357 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001358 }
1359}
1360
1361func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001362 // Start port monitoring when the agent loop begins
1363 // Only monitor ports when running in a container
1364 if a.IsInContainer() {
1365 a.portMonitor.Start(ctxOuter)
1366 }
1367
Earl Lee2e463fb2025-04-17 11:22:22 -07001368 for {
1369 select {
1370 case <-ctxOuter.Done():
1371 return
1372 default:
1373 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001374 a.cancelTurnMu.Lock()
1375 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001376 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001377 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001378 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001379 a.cancelTurn = cancel
1380 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001381 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1382 if err != nil {
1383 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1384 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001385 cancel(nil)
1386 }
1387 }
1388}
1389
1390func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1391 if m.Timestamp.IsZero() {
1392 m.Timestamp = time.Now()
1393 }
1394
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001395 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1396 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1397 m.Content = m.ToolResult
1398 }
1399
Earl Lee2e463fb2025-04-17 11:22:22 -07001400 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1401 if m.EndOfTurn && m.Type == AgentMessageType {
1402 turnDuration := time.Since(a.startOfTurn)
1403 m.TurnDuration = &turnDuration
1404 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1405 }
1406
Earl Lee2e463fb2025-04-17 11:22:22 -07001407 a.mu.Lock()
1408 defer a.mu.Unlock()
1409 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001410 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001411 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001412
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001413 // Notify all subscribers
1414 for _, ch := range a.subscribers {
1415 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001416 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001417}
1418
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001419func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1420 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001421 if block {
1422 select {
1423 case <-ctx.Done():
1424 return m, ctx.Err()
1425 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001426 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001427 }
1428 }
1429 for {
1430 select {
1431 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001432 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001433 default:
1434 return m, nil
1435 }
1436 }
1437}
1438
Sean McCullough885a16a2025-04-30 02:49:25 +00001439// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001440func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001441 // Reset the start of turn time
1442 a.startOfTurn = time.Now()
1443
Sean McCullough96b60dd2025-04-30 09:49:10 -07001444 // Transition to waiting for user input state
1445 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1446
Sean McCullough885a16a2025-04-30 02:49:25 +00001447 // Process initial user message
1448 initialResp, err := a.processUserMessage(ctx)
1449 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001450 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001451 return err
1452 }
1453
1454 // Handle edge case where both initialResp and err are nil
1455 if initialResp == nil {
1456 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001457 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1458
Sean McCullough9f4b8082025-04-30 17:34:07 +00001459 a.pushToOutbox(ctx, errorMessage(err))
1460 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001461 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001462
Earl Lee2e463fb2025-04-17 11:22:22 -07001463 // We do this as we go, but let's also do it at the end of the turn
1464 defer func() {
1465 if _, err := a.handleGitCommits(ctx); err != nil {
1466 // Just log the error, don't stop execution
1467 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1468 }
1469 }()
1470
Sean McCullougha1e0e492025-05-01 10:51:08 -07001471 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001472 resp := initialResp
1473 for {
1474 // Check if we are over budget
1475 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001476 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001477 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001478 }
1479
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001480 // Check if we should compact the conversation
1481 if a.ShouldCompact() {
1482 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1483 if err := a.CompactConversation(ctx); err != nil {
1484 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1485 return err
1486 }
1487 // After compaction, end this turn and start fresh
1488 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1489 return nil
1490 }
1491
Sean McCullough885a16a2025-04-30 02:49:25 +00001492 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001493 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001494 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001495 break
1496 }
1497
Sean McCullough96b60dd2025-04-30 09:49:10 -07001498 // Transition to tool use requested state
1499 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1500
Sean McCullough885a16a2025-04-30 02:49:25 +00001501 // Handle tool execution
1502 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1503 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001504 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001505 }
1506
Sean McCullougha1e0e492025-05-01 10:51:08 -07001507 if toolResp == nil {
1508 return fmt.Errorf("cannot continue conversation with a nil tool response")
1509 }
1510
Sean McCullough885a16a2025-04-30 02:49:25 +00001511 // Set the response for the next iteration
1512 resp = toolResp
1513 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001514
1515 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001516}
1517
1518// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001519func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001520 // Wait for at least one message from the user
1521 msgs, err := a.GatherMessages(ctx, true)
1522 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001523 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001524 return nil, err
1525 }
1526
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001527 userMessage := llm.Message{
1528 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001529 Content: msgs,
1530 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001531
Sean McCullough96b60dd2025-04-30 09:49:10 -07001532 // Transition to sending to LLM state
1533 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1534
Sean McCullough885a16a2025-04-30 02:49:25 +00001535 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001536 resp, err := a.convo.SendMessage(userMessage)
1537 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001538 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001539 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001540 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001541 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001542
Sean McCullough96b60dd2025-04-30 09:49:10 -07001543 // Transition to processing LLM response state
1544 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1545
Sean McCullough885a16a2025-04-30 02:49:25 +00001546 return resp, nil
1547}
1548
1549// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001550func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1551 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001552 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001553 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001554
Sean McCullough96b60dd2025-04-30 09:49:10 -07001555 // Transition to checking for cancellation state
1556 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1557
Sean McCullough885a16a2025-04-30 02:49:25 +00001558 // Check if the operation was cancelled by the user
1559 select {
1560 case <-ctx.Done():
1561 // Don't actually run any of the tools, but rather build a response
1562 // for each tool_use message letting the LLM know that user canceled it.
1563 var err error
1564 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001565 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001566 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001567 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001568 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001569 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001570 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001571 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001572 // Transition to running tool state
1573 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1574
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001575 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001576 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001577 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001578
1579 // Execute the tools
1580 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001581 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001582 if ctx.Err() != nil { // e.g. the user canceled the operation
1583 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001584 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001585 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001586 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001587 a.pushToOutbox(ctx, errorMessage(err))
1588 }
1589 }
1590
1591 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001592 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001593 autoqualityMessages := a.processGitChanges(ctx)
1594
1595 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001596 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001597 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001598 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001599 return false, nil
1600 }
1601
1602 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001603 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1604 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001605}
1606
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001607// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001608func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001609 // Check for git commits
1610 _, err := a.handleGitCommits(ctx)
1611 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001612 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001613 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001614 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001615 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001616}
1617
1618// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1619// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001620func (a *Agent) processGitChanges(ctx context.Context) []string {
1621 // Check for git commits after tool execution
1622 newCommits, err := a.handleGitCommits(ctx)
1623 if err != nil {
1624 // Just log the error, don't stop execution
1625 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1626 return nil
1627 }
1628
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001629 // Run mechanical checks if there was exactly one new commit.
1630 if len(newCommits) != 1 {
1631 return nil
1632 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001633 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001634 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1635 msg := a.codereview.RunMechanicalChecks(ctx)
1636 if msg != "" {
1637 a.pushToOutbox(ctx, AgentMessage{
1638 Type: AutoMessageType,
1639 Content: msg,
1640 Timestamp: time.Now(),
1641 })
1642 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001643 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001644
1645 return autoqualityMessages
1646}
1647
1648// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001649func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001650 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001651 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001652 msgs, err := a.GatherMessages(ctx, false)
1653 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001654 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001655 return false, nil
1656 }
1657
1658 // Inject any auto-generated messages from quality checks
1659 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001660 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001661 }
1662
1663 // Handle cancellation by appending a message about it
1664 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001665 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001666 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001667 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001668 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1669 } else if err := a.convo.OverBudget(); err != nil {
1670 // Handle budget issues by appending a message about it
1671 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 -07001672 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001673 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1674 }
1675
1676 // Combine tool results with user messages
1677 results = append(results, msgs...)
1678
1679 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001680 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001681 resp, err := a.convo.SendMessage(llm.Message{
1682 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001683 Content: results,
1684 })
1685 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001686 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001687 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1688 return true, nil // Return true to continue the conversation, but with no response
1689 }
1690
Sean McCullough96b60dd2025-04-30 09:49:10 -07001691 // Transition back to processing LLM response
1692 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1693
Sean McCullough885a16a2025-04-30 02:49:25 +00001694 if cancelled {
1695 return false, nil
1696 }
1697
1698 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001699}
1700
1701func (a *Agent) overBudget(ctx context.Context) error {
1702 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001703 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001704 m := budgetMessage(err)
1705 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001706 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001707 a.convo.ResetBudget(a.originalBudget)
1708 return err
1709 }
1710 return nil
1711}
1712
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001713func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001714 // Collect all text content
1715 var allText strings.Builder
1716 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001717 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001718 if allText.Len() > 0 {
1719 allText.WriteString("\n\n")
1720 }
1721 allText.WriteString(content.Text)
1722 }
1723 }
1724 return allText.String()
1725}
1726
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001727func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001728 a.mu.Lock()
1729 defer a.mu.Unlock()
1730 return a.convo.CumulativeUsage()
1731}
1732
Earl Lee2e463fb2025-04-17 11:22:22 -07001733// Diff returns a unified diff of changes made since the agent was instantiated.
1734func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001735 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001736 return "", fmt.Errorf("no initial commit reference available")
1737 }
1738
1739 // Find the repository root
1740 ctx := context.Background()
1741
1742 // If a specific commit hash is provided, show just that commit's changes
1743 if commit != nil && *commit != "" {
1744 // Validate that the commit looks like a valid git SHA
1745 if !isValidGitSHA(*commit) {
1746 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1747 }
1748
1749 // Get the diff for just this commit
1750 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1751 cmd.Dir = a.repoRoot
1752 output, err := cmd.CombinedOutput()
1753 if err != nil {
1754 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1755 }
1756 return string(output), nil
1757 }
1758
1759 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001760 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001761 cmd.Dir = a.repoRoot
1762 output, err := cmd.CombinedOutput()
1763 if err != nil {
1764 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1765 }
1766
1767 return string(output), nil
1768}
1769
Philip Zeyliger49edc922025-05-14 09:45:45 -07001770// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1771// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1772func (a *Agent) SketchGitBaseRef() string {
1773 if a.IsInContainer() {
1774 return "sketch-base"
1775 } else {
1776 return "sketch-base-" + a.SessionID()
1777 }
1778}
1779
1780// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1781func (a *Agent) SketchGitBase() string {
1782 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1783 cmd.Dir = a.repoRoot
1784 output, err := cmd.CombinedOutput()
1785 if err != nil {
1786 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1787 return "HEAD"
1788 }
1789 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001790}
1791
Pokey Rule7a113622025-05-12 10:58:45 +01001792// removeGitHooks removes the Git hooks directory from the repository
1793func removeGitHooks(_ context.Context, repoPath string) error {
1794 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1795
1796 // Check if hooks directory exists
1797 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1798 // Directory doesn't exist, nothing to do
1799 return nil
1800 }
1801
1802 // Remove the hooks directory
1803 err := os.RemoveAll(hooksDir)
1804 if err != nil {
1805 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1806 }
1807
1808 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001809 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001810 if err != nil {
1811 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1812 }
1813
1814 return nil
1815}
1816
Philip Zeyligerf2872992025-05-22 10:35:28 -07001817func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
1818 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef())
1819 for _, msg := range msgs {
1820 a.pushToOutbox(ctx, msg)
1821 }
1822 return commits, error
1823}
1824
Earl Lee2e463fb2025-04-17 11:22:22 -07001825// handleGitCommits() highlights new commits to the user. When running
1826// under docker, new HEADs are pushed to a branch according to the title.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001827func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string) ([]AgentMessage, []*GitCommit, error) {
1828 ags.mu.Lock()
1829 defer ags.mu.Unlock()
1830
1831 msgs := []AgentMessage{}
1832 if repoRoot == "" {
1833 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001834 }
1835
Philip Zeyligerf2872992025-05-22 10:35:28 -07001836 head, err := resolveRef(ctx, repoRoot, "HEAD")
Earl Lee2e463fb2025-04-17 11:22:22 -07001837 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001838 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001839 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001840 if head == ags.lastHEAD {
1841 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001842 }
1843 defer func() {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001844 ags.lastHEAD = head
Earl Lee2e463fb2025-04-17 11:22:22 -07001845 }()
1846
1847 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1848 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1849 // to the last 100 commits.
1850 var commits []*GitCommit
1851
1852 // Get commits since the initial commit
1853 // Format: <hash>\0<subject>\0<body>\0
1854 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1855 // Limit to 100 commits to avoid overwhelming the user
Philip Zeyligerf2872992025-05-22 10:35:28 -07001856 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, head)
1857 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001858 output, err := cmd.Output()
1859 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001860 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001861 }
1862
1863 // Parse git log output and filter out already seen commits
1864 parsedCommits := parseGitLog(string(output))
1865
1866 var headCommit *GitCommit
1867
1868 // Filter out commits we've already seen
1869 for _, commit := range parsedCommits {
1870 if commit.Hash == head {
1871 headCommit = &commit
1872 }
1873
1874 // Skip if we've seen this commit before. If our head has changed, always include that.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001875 if ags.seenCommits[commit.Hash] && commit.Hash != head {
Earl Lee2e463fb2025-04-17 11:22:22 -07001876 continue
1877 }
1878
1879 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001880 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001881
1882 // Add to our list of new commits
1883 commits = append(commits, &commit)
1884 }
1885
Philip Zeyligerf2872992025-05-22 10:35:28 -07001886 if ags.gitRemoteAddr != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001887 if headCommit == nil {
1888 // I think this can only happen if we have a bug or if there's a race.
1889 headCommit = &GitCommit{}
1890 headCommit.Hash = head
1891 headCommit.Subject = "unknown"
1892 commits = append(commits, headCommit)
1893 }
1894
Philip Zeyligerf2872992025-05-22 10:35:28 -07001895 originalBranch := cmp.Or(ags.branchName, "sketch/"+sessionID)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001896 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001897
1898 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1899 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1900 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001901
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001902 // Parse the original branch name to extract base name and starting number
1903 baseBranch, startNum := parseBranchNameAndNumber(originalBranch)
1904
Philip Zeyliger113e2052025-05-09 21:59:40 +00001905 // Try up to 10 times with different branch names if the branch is checked out on the remote
1906 var out []byte
1907 var err error
1908 for retries := range 10 {
1909 if retries > 0 {
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001910 // Increment from the starting number (foo1->foo2, foo2->foo3, etc.)
1911 branch = fmt.Sprintf("%s%d", baseBranch, startNum+retries)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001912 }
1913
Philip Zeyligerf2872992025-05-22 10:35:28 -07001914 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1915 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001916 out, err = cmd.CombinedOutput()
1917
1918 if err == nil {
1919 // Success! Break out of the retry loop
1920 break
1921 }
1922
1923 // Check if this is the "refusing to update checked out branch" error
1924 if !strings.Contains(string(out), "refusing to update checked out branch") {
1925 // This is a different error, so don't retry
1926 break
1927 }
1928
1929 // If we're on the last retry, we'll report the error
1930 if retries == 9 {
1931 break
1932 }
1933 }
1934
1935 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001936 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001937 } else {
1938 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001939 // Update the agent's branch name if we ended up using a different one
1940 if branch != originalBranch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001941 ags.branchName = branch
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001942 // Notify user why the branch name was changed
1943 msgs = append(msgs, AgentMessage{
1944 Type: AutoMessageType,
1945 Timestamp: time.Now(),
1946 Content: fmt.Sprintf("Branch renamed from %s to %s because the original branch is currently checked out on the remote.", originalBranch, branch),
1947 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00001948 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001949 }
1950 }
1951
1952 // If we found new commits, create a message
1953 if len(commits) > 0 {
1954 msg := AgentMessage{
1955 Type: CommitMessageType,
1956 Timestamp: time.Now(),
1957 Commits: commits,
1958 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001959 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001960 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001961 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001962}
1963
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001964func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001965 return strings.Map(func(r rune) rune {
1966 // lowercase
1967 if r >= 'A' && r <= 'Z' {
1968 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001969 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001970 // replace spaces with dashes
1971 if r == ' ' {
1972 return '-'
1973 }
1974 // allow alphanumerics and dashes
1975 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1976 return r
1977 }
1978 return -1
1979 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001980}
1981
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001982// parseBranchNameAndNumber extracts the base branch name and starting number.
1983// For "sketch/foo1" returns ("sketch/foo", 1)
1984// For "sketch/foo" returns ("sketch/foo", 0)
1985func parseBranchNameAndNumber(branchName string) (baseBranch string, startNum int) {
1986 re := regexp.MustCompile(`^(.+?)(\d+)$`)
1987 matches := re.FindStringSubmatch(branchName)
1988
1989 if len(matches) != 3 {
1990 // No trailing digits found
1991 return branchName, 0
1992 }
1993
1994 num, err := strconv.Atoi(matches[2])
1995 if err != nil {
1996 // If parsing fails, treat as no number
1997 return branchName, 0
1998 }
1999
2000 return matches[1], num
2001}
2002
Earl Lee2e463fb2025-04-17 11:22:22 -07002003// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2004// and returns an array of GitCommit structs.
2005func parseGitLog(output string) []GitCommit {
2006 var commits []GitCommit
2007
2008 // No output means no commits
2009 if len(output) == 0 {
2010 return commits
2011 }
2012
2013 // Split by NULL byte
2014 parts := strings.Split(output, "\x00")
2015
2016 // Process in triplets (hash, subject, body)
2017 for i := 0; i < len(parts); i++ {
2018 // Skip empty parts
2019 if parts[i] == "" {
2020 continue
2021 }
2022
2023 // This should be a hash
2024 hash := strings.TrimSpace(parts[i])
2025
2026 // Make sure we have at least a subject part available
2027 if i+1 >= len(parts) {
2028 break // No more parts available
2029 }
2030
2031 // Get the subject
2032 subject := strings.TrimSpace(parts[i+1])
2033
2034 // Get the body if available
2035 body := ""
2036 if i+2 < len(parts) {
2037 body = strings.TrimSpace(parts[i+2])
2038 }
2039
2040 // Skip to the next triplet
2041 i += 2
2042
2043 commits = append(commits, GitCommit{
2044 Hash: hash,
2045 Subject: subject,
2046 Body: body,
2047 })
2048 }
2049
2050 return commits
2051}
2052
2053func repoRoot(ctx context.Context, dir string) (string, error) {
2054 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2055 stderr := new(strings.Builder)
2056 cmd.Stderr = stderr
2057 cmd.Dir = dir
2058 out, err := cmd.Output()
2059 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002060 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002061 }
2062 return strings.TrimSpace(string(out)), nil
2063}
2064
2065func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2066 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2067 stderr := new(strings.Builder)
2068 cmd.Stderr = stderr
2069 cmd.Dir = dir
2070 out, err := cmd.Output()
2071 if err != nil {
2072 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2073 }
2074 // TODO: validate that out is valid hex
2075 return strings.TrimSpace(string(out)), nil
2076}
2077
2078// isValidGitSHA validates if a string looks like a valid git SHA hash.
2079// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2080func isValidGitSHA(sha string) bool {
2081 // Git SHA must be a hexadecimal string with at least 4 characters
2082 if len(sha) < 4 || len(sha) > 40 {
2083 return false
2084 }
2085
2086 // Check if the string only contains hexadecimal characters
2087 for _, char := range sha {
2088 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2089 return false
2090 }
2091 }
2092
2093 return true
2094}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002095
2096// getGitOrigin returns the URL of the git remote 'origin' if it exists
2097func getGitOrigin(ctx context.Context, dir string) string {
2098 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2099 cmd.Dir = dir
2100 stderr := new(strings.Builder)
2101 cmd.Stderr = stderr
2102 out, err := cmd.Output()
2103 if err != nil {
2104 return ""
2105 }
2106 return strings.TrimSpace(string(out))
2107}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002108
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002109// systemPromptData contains the data used to render the system prompt template
2110type systemPromptData struct {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002111 ClientGOOS string
2112 ClientGOARCH string
2113 WorkingDir string
2114 RepoRoot string
2115 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002116 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002117}
2118
2119// renderSystemPrompt renders the system prompt template.
2120func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002121 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002122 ClientGOOS: a.config.ClientGOOS,
2123 ClientGOARCH: a.config.ClientGOARCH,
2124 WorkingDir: a.workingDir,
2125 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002126 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002127 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002128 }
2129
2130 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2131 if err != nil {
2132 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2133 }
2134 buf := new(strings.Builder)
2135 err = tmpl.Execute(buf, data)
2136 if err != nil {
2137 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2138 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002139 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002140 return buf.String()
2141}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002142
2143// StateTransitionIterator provides an iterator over state transitions.
2144type StateTransitionIterator interface {
2145 // Next blocks until a new state transition is available or context is done.
2146 // Returns nil if the context is cancelled.
2147 Next() *StateTransition
2148 // Close removes the listener and cleans up resources.
2149 Close()
2150}
2151
2152// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2153type StateTransitionIteratorImpl struct {
2154 agent *Agent
2155 ctx context.Context
2156 ch chan StateTransition
2157 unsubscribe func()
2158}
2159
2160// Next blocks until a new state transition is available or the context is cancelled.
2161func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2162 select {
2163 case <-s.ctx.Done():
2164 return nil
2165 case transition, ok := <-s.ch:
2166 if !ok {
2167 return nil
2168 }
2169 transitionCopy := transition
2170 return &transitionCopy
2171 }
2172}
2173
2174// Close removes the listener and cleans up resources.
2175func (s *StateTransitionIteratorImpl) Close() {
2176 if s.unsubscribe != nil {
2177 s.unsubscribe()
2178 s.unsubscribe = nil
2179 }
2180}
2181
2182// NewStateTransitionIterator returns an iterator that receives state transitions.
2183func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2184 a.mu.Lock()
2185 defer a.mu.Unlock()
2186
2187 // Create channel to receive state transitions
2188 ch := make(chan StateTransition, 10)
2189
2190 // Add a listener to the state machine
2191 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2192
2193 return &StateTransitionIteratorImpl{
2194 agent: a,
2195 ctx: ctx,
2196 ch: ch,
2197 unsubscribe: unsubscribe,
2198 }
2199}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002200
2201// setupGitHooks creates or updates git hooks in the specified working directory.
2202func setupGitHooks(workingDir string) error {
2203 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2204
2205 _, err := os.Stat(hooksDir)
2206 if os.IsNotExist(err) {
2207 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2208 }
2209 if err != nil {
2210 return fmt.Errorf("error checking git hooks directory: %w", err)
2211 }
2212
2213 // Define the post-commit hook content
2214 postCommitHook := `#!/bin/bash
2215echo "<post_commit_hook>"
2216echo "Please review this commit message and fix it if it is incorrect."
2217echo "This hook only echos the commit message; it does not modify it."
2218echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2219echo "<last_commit_message>"
2220git log -1 --pretty=%B
2221echo "</last_commit_message>"
2222echo "</post_commit_hook>"
2223`
2224
2225 // Define the prepare-commit-msg hook content
2226 prepareCommitMsgHook := `#!/bin/bash
2227# Add Co-Authored-By and Change-ID trailers to commit messages
2228# Check if these trailers already exist before adding them
2229
2230commit_file="$1"
2231COMMIT_SOURCE="$2"
2232
2233# Skip for merges, squashes, or when using a commit template
2234if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2235 [ "$COMMIT_SOURCE" = "squash" ]; then
2236 exit 0
2237fi
2238
2239commit_msg=$(cat "$commit_file")
2240
2241needs_co_author=true
2242needs_change_id=true
2243
2244# Check if commit message already has Co-Authored-By trailer
2245if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2246 needs_co_author=false
2247fi
2248
2249# Check if commit message already has Change-ID trailer
2250if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2251 needs_change_id=false
2252fi
2253
2254# Only modify if at least one trailer needs to be added
2255if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002256 # Ensure there's a proper blank line before trailers
2257 if [ -s "$commit_file" ]; then
2258 # Check if file ends with newline by reading last character
2259 last_char=$(tail -c 1 "$commit_file")
2260
2261 if [ "$last_char" != "" ]; then
2262 # File doesn't end with newline - add two newlines (complete line + blank line)
2263 echo "" >> "$commit_file"
2264 echo "" >> "$commit_file"
2265 else
2266 # File ends with newline - check if we already have a blank line
2267 last_line=$(tail -1 "$commit_file")
2268 if [ -n "$last_line" ]; then
2269 # Last line has content - add one newline for blank line
2270 echo "" >> "$commit_file"
2271 fi
2272 # If last line is empty, we already have a blank line - don't add anything
2273 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002274 fi
2275
2276 # Add trailers if needed
2277 if [ "$needs_co_author" = true ]; then
2278 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2279 fi
2280
2281 if [ "$needs_change_id" = true ]; then
2282 change_id=$(openssl rand -hex 8)
2283 echo "Change-ID: s${change_id}k" >> "$commit_file"
2284 fi
2285fi
2286`
2287
2288 // Update or create the post-commit hook
2289 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2290 if err != nil {
2291 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2292 }
2293
2294 // Update or create the prepare-commit-msg hook
2295 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2296 if err != nil {
2297 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2298 }
2299
2300 return nil
2301}
2302
2303// updateOrCreateHook creates a new hook file or updates an existing one
2304// by appending the new content if it doesn't already contain it.
2305func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2306 // Check if the hook already exists
2307 buf, err := os.ReadFile(hookPath)
2308 if os.IsNotExist(err) {
2309 // Hook doesn't exist, create it
2310 err = os.WriteFile(hookPath, []byte(content), 0o755)
2311 if err != nil {
2312 return fmt.Errorf("failed to create hook: %w", err)
2313 }
2314 return nil
2315 }
2316 if err != nil {
2317 return fmt.Errorf("error reading existing hook: %w", err)
2318 }
2319
2320 // Hook exists, check if our content is already in it by looking for a distinctive line
2321 code := string(buf)
2322 if strings.Contains(code, distinctiveLine) {
2323 // Already contains our content, nothing to do
2324 return nil
2325 }
2326
2327 // Append our content to the existing hook
2328 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2329 if err != nil {
2330 return fmt.Errorf("failed to open hook for appending: %w", err)
2331 }
2332 defer f.Close()
2333
2334 // Ensure there's a newline at the end of the existing content if needed
2335 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2336 _, err = f.WriteString("\n")
2337 if err != nil {
2338 return fmt.Errorf("failed to add newline to hook: %w", err)
2339 }
2340 }
2341
2342 // Add a separator before our content
2343 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2344 if err != nil {
2345 return fmt.Errorf("failed to append to hook: %w", err)
2346 }
2347
2348 return nil
2349}
Sean McCullough138ec242025-06-02 22:42:06 +00002350
2351// GetPortMonitor returns the port monitor instance for accessing port events
2352func (a *Agent) GetPortMonitor() *PortMonitor {
2353 return a.portMonitor
2354}