blob: 949682b5f8b04495bd8ffb34fa78f726db273b91 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package loop
2
3import (
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07004 "cmp"
Earl Lee2e463fb2025-04-17 11:22:22 -07005 "context"
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07006 _ "embed"
Earl Lee2e463fb2025-04-17 11:22:22 -07007 "encoding/json"
8 "fmt"
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +00009 "io"
Earl Lee2e463fb2025-04-17 11:22:22 -070010 "log/slog"
11 "net/http"
12 "os"
13 "os/exec"
Pokey Rule7a113622025-05-12 10:58:45 +010014 "path/filepath"
Philip Zeyliger59e1c162025-06-02 12:54:34 +000015 "regexp"
Earl Lee2e463fb2025-04-17 11:22:22 -070016 "runtime/debug"
17 "slices"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070018 "strconv"
Earl Lee2e463fb2025-04-17 11:22:22 -070019 "strings"
20 "sync"
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +000021 "text/template"
Earl Lee2e463fb2025-04-17 11:22:22 -070022 "time"
23
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000024 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070025 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000026 "sketch.dev/claudetool/bashkit"
Autoformatter4962f152025-05-06 17:24:20 +000027 "sketch.dev/claudetool/browse"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000028 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydera997be62025-05-07 22:52:46 +000029 "sketch.dev/claudetool/onstart"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070030 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070031 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070032 "sketch.dev/llm/conversation"
Earl Lee2e463fb2025-04-17 11:22:22 -070033)
34
35const (
36 userCancelMessage = "user requested agent to stop handling responses"
37)
38
Philip Zeyligerb7c58752025-05-01 10:10:17 -070039type MessageIterator interface {
40 // Next blocks until the next message is available. It may
41 // return nil if the underlying iterator context is done.
42 Next() *AgentMessage
43 Close()
44}
45
Earl Lee2e463fb2025-04-17 11:22:22 -070046type CodingAgent interface {
47 // Init initializes an agent inside a docker container.
48 Init(AgentInit) error
49
50 // Ready returns a channel closed after Init successfully called.
51 Ready() <-chan struct{}
52
53 // URL reports the HTTP URL of this agent.
54 URL() string
55
56 // UserMessage enqueues a message to the agent and returns immediately.
57 UserMessage(ctx context.Context, msg string)
58
Philip Zeyligerb7c58752025-05-01 10:10:17 -070059 // Returns an iterator that finishes when the context is done and
60 // starts with the given message index.
61 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070062
Philip Zeyligereab12de2025-05-14 02:35:53 +000063 // Returns an iterator that notifies of state transitions until the context is done.
64 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
65
Earl Lee2e463fb2025-04-17 11:22:22 -070066 // Loop begins the agent loop returns only when ctx is cancelled.
67 Loop(ctx context.Context)
68
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000069 // BranchPrefix returns the configured branch prefix
70 BranchPrefix() string
71
Sean McCulloughedc88dc2025-04-30 02:55:01 +000072 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070073
74 CancelToolUse(toolUseID string, cause error) error
75
76 // Returns a subset of the agent's message history.
77 Messages(start int, end int) []AgentMessage
78
79 // Returns the current number of messages in the history
80 MessageCount() int
81
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070082 TotalUsage() conversation.CumulativeUsage
83 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070084
Earl Lee2e463fb2025-04-17 11:22:22 -070085 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000086 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070087
88 // Diff returns a unified diff of changes made since the agent was instantiated.
89 // If commit is non-nil, it shows the diff for just that specific commit.
90 Diff(commit *string) (string, error)
91
Philip Zeyliger49edc922025-05-14 09:45:45 -070092 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
93 // starts out as the commit where sketch started, but a user can move it if need
94 // be, for example in the case of a rebase. It is stored as a git tag.
95 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070096
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000097 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
98 // (Typically, this is "sketch-base")
99 SketchGitBaseRef() string
100
Earl Lee2e463fb2025-04-17 11:22:22 -0700101 // Title returns the current title of the conversation.
102 Title() string
103
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000104 // BranchName returns the git branch name for the conversation.
105 BranchName() string
106
Earl Lee2e463fb2025-04-17 11:22:22 -0700107 // OS returns the operating system of the client.
108 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000109
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000110 // SessionID returns the unique session identifier.
111 SessionID() string
112
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000113 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700114 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000115
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000116 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
117 OutstandingLLMCallCount() int
118
119 // OutstandingToolCalls returns the names of outstanding tool calls.
120 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000121 OutsideOS() string
122 OutsideHostname() string
123 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000124 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000125 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
126 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700127
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700128 // IsInContainer returns true if the agent is running in a container
129 IsInContainer() bool
130 // FirstMessageIndex returns the index of the first message in the current conversation
131 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700132
133 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700134 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
135 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700136
137 // CompactConversation compacts the current conversation by generating a summary
138 // and restarting the conversation with that summary as the initial context
139 CompactConversation(ctx context.Context) error
Sean McCullough138ec242025-06-02 22:42:06 +0000140 // GetPortMonitor returns the port monitor instance for accessing port events
141 GetPortMonitor() *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700142}
143
144type CodingAgentMessageType string
145
146const (
147 UserMessageType CodingAgentMessageType = "user"
148 AgentMessageType CodingAgentMessageType = "agent"
149 ErrorMessageType CodingAgentMessageType = "error"
150 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
151 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700152 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
153 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
154 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700155
156 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
157)
158
159type AgentMessage struct {
160 Type CodingAgentMessageType `json:"type"`
161 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
162 EndOfTurn bool `json:"end_of_turn"`
163
164 Content string `json:"content"`
165 ToolName string `json:"tool_name,omitempty"`
166 ToolInput string `json:"input,omitempty"`
167 ToolResult string `json:"tool_result,omitempty"`
168 ToolError bool `json:"tool_error,omitempty"`
169 ToolCallId string `json:"tool_call_id,omitempty"`
170
171 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
172 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
173
Sean McCulloughd9f13372025-04-21 15:08:49 -0700174 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
175 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
176
Earl Lee2e463fb2025-04-17 11:22:22 -0700177 // Commits is a list of git commits for a commit message
178 Commits []*GitCommit `json:"commits,omitempty"`
179
180 Timestamp time.Time `json:"timestamp"`
181 ConversationID string `json:"conversation_id"`
182 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700183 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700184
185 // Message timing information
186 StartTime *time.Time `json:"start_time,omitempty"`
187 EndTime *time.Time `json:"end_time,omitempty"`
188 Elapsed *time.Duration `json:"elapsed,omitempty"`
189
190 // Turn duration - the time taken for a complete agent turn
191 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
192
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000193 // HideOutput indicates that this message should not be rendered in the UI.
194 // This is useful for subconversations that generate output that shouldn't be shown to the user.
195 HideOutput bool `json:"hide_output,omitempty"`
196
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700197 // TodoContent contains the agent's todo file content when it has changed
198 TodoContent *string `json:"todo_content,omitempty"`
199
Earl Lee2e463fb2025-04-17 11:22:22 -0700200 Idx int `json:"idx"`
201}
202
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000203// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700204func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700205 if convo == nil {
206 m.ConversationID = ""
207 m.ParentConversationID = nil
208 return
209 }
210 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000211 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700212 if convo.Parent != nil {
213 m.ParentConversationID = &convo.Parent.ID
214 }
215}
216
Earl Lee2e463fb2025-04-17 11:22:22 -0700217// GitCommit represents a single git commit for a commit message
218type GitCommit struct {
219 Hash string `json:"hash"` // Full commit hash
220 Subject string `json:"subject"` // Commit subject line
221 Body string `json:"body"` // Full commit message body
222 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
223}
224
225// ToolCall represents a single tool call within an agent message
226type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700227 Name string `json:"name"`
228 Input string `json:"input"`
229 ToolCallId string `json:"tool_call_id"`
230 ResultMessage *AgentMessage `json:"result_message,omitempty"`
231 Args string `json:"args,omitempty"`
232 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700233}
234
235func (a *AgentMessage) Attr() slog.Attr {
236 var attrs []any = []any{
237 slog.String("type", string(a.Type)),
238 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700239 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700240 if a.EndOfTurn {
241 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
242 }
243 if a.Content != "" {
244 attrs = append(attrs, slog.String("content", a.Content))
245 }
246 if a.ToolName != "" {
247 attrs = append(attrs, slog.String("tool_name", a.ToolName))
248 }
249 if a.ToolInput != "" {
250 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
251 }
252 if a.Elapsed != nil {
253 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
254 }
255 if a.TurnDuration != nil {
256 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
257 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700258 if len(a.ToolResult) > 0 {
259 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700260 }
261 if a.ToolError {
262 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
263 }
264 if len(a.ToolCalls) > 0 {
265 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
266 for i, tc := range a.ToolCalls {
267 toolCallAttrs = append(toolCallAttrs, slog.Group(
268 fmt.Sprintf("tool_call_%d", i),
269 slog.String("name", tc.Name),
270 slog.String("input", tc.Input),
271 ))
272 }
273 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
274 }
275 if a.ConversationID != "" {
276 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
277 }
278 if a.ParentConversationID != nil {
279 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
280 }
281 if a.Usage != nil && !a.Usage.IsZero() {
282 attrs = append(attrs, a.Usage.Attr())
283 }
284 // TODO: timestamp, convo ids, idx?
285 return slog.Group("agent_message", attrs...)
286}
287
288func errorMessage(err error) AgentMessage {
289 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
290 if os.Getenv(("DEBUG")) == "1" {
291 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
292 }
293
294 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
295}
296
297func budgetMessage(err error) AgentMessage {
298 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
299}
300
301// ConvoInterface defines the interface for conversation interactions
302type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700303 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700304 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700305 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700306 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700307 SendMessage(message llm.Message) (*llm.Response, error)
308 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700309 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000310 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700311 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700312 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700313 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700314}
315
Philip Zeyligerf2872992025-05-22 10:35:28 -0700316// AgentGitState holds the state necessary for pushing to a remote git repo
317// when HEAD changes. If gitRemoteAddr is set, then we push to sketch/
318// any time we notice we need to.
319type AgentGitState struct {
320 mu sync.Mutex // protects following
321 lastHEAD string // hash of the last HEAD that was pushed to the host
322 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000323 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700324 seenCommits map[string]bool // Track git commits we've already seen (by hash)
325 branchName string
326}
327
328func (ags *AgentGitState) SetBranchName(branchName string) {
329 ags.mu.Lock()
330 defer ags.mu.Unlock()
331 ags.branchName = branchName
332}
333
334func (ags *AgentGitState) BranchName() string {
335 ags.mu.Lock()
336 defer ags.mu.Unlock()
337 return ags.branchName
338}
339
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000340func (ags *AgentGitState) Upstream() string {
341 ags.mu.Lock()
342 defer ags.mu.Unlock()
343 return ags.upstream
344}
345
Earl Lee2e463fb2025-04-17 11:22:22 -0700346type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700347 convo ConvoInterface
348 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700349 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700350 workingDir string
351 repoRoot string // workingDir may be a subdir of repoRoot
352 url string
353 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000354 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700355 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000356 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700357 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700358 originalBudget conversation.Budget
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700359 title string
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000360 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700361 // State machine to track agent state
362 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000363 // Outside information
364 outsideHostname string
365 outsideOS string
366 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000367 // URL of the git remote 'origin' if it exists
368 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700369
370 // Time when the current turn started (reset at the beginning of InnerLoop)
371 startOfTurn time.Time
372
373 // Inbox - for messages from the user to the agent.
374 // sent on by UserMessage
375 // . e.g. when user types into the chat textarea
376 // read from by GatherMessages
377 inbox chan string
378
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000379 // protects cancelTurn
380 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700381 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000382 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700383
384 // protects following
385 mu sync.Mutex
386
387 // Stores all messages for this agent
388 history []AgentMessage
389
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700390 // Iterators add themselves here when they're ready to be notified of new messages.
391 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700392
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000393 // Track outstanding LLM call IDs
394 outstandingLLMCalls map[string]struct{}
395
396 // Track outstanding tool calls by ID with their names
397 outstandingToolCalls map[string]string
Sean McCullough364f7412025-06-02 00:55:44 +0000398
399 // Port monitoring
400 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700401}
402
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700403// NewIterator implements CodingAgent.
404func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
405 a.mu.Lock()
406 defer a.mu.Unlock()
407
408 return &MessageIteratorImpl{
409 agent: a,
410 ctx: ctx,
411 nextMessageIdx: nextMessageIdx,
412 ch: make(chan *AgentMessage, 100),
413 }
414}
415
416type MessageIteratorImpl struct {
417 agent *Agent
418 ctx context.Context
419 nextMessageIdx int
420 ch chan *AgentMessage
421 subscribed bool
422}
423
424func (m *MessageIteratorImpl) Close() {
425 m.agent.mu.Lock()
426 defer m.agent.mu.Unlock()
427 // Delete ourselves from the subscribers list
428 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
429 return x == m.ch
430 })
431 close(m.ch)
432}
433
434func (m *MessageIteratorImpl) Next() *AgentMessage {
435 // We avoid subscription at creation to let ourselves catch up to "current state"
436 // before subscribing.
437 if !m.subscribed {
438 m.agent.mu.Lock()
439 if m.nextMessageIdx < len(m.agent.history) {
440 msg := &m.agent.history[m.nextMessageIdx]
441 m.nextMessageIdx++
442 m.agent.mu.Unlock()
443 return msg
444 }
445 // The next message doesn't exist yet, so let's subscribe
446 m.agent.subscribers = append(m.agent.subscribers, m.ch)
447 m.subscribed = true
448 m.agent.mu.Unlock()
449 }
450
451 for {
452 select {
453 case <-m.ctx.Done():
454 m.agent.mu.Lock()
455 // Delete ourselves from the subscribers list
456 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
457 return x == m.ch
458 })
459 m.subscribed = false
460 m.agent.mu.Unlock()
461 return nil
462 case msg, ok := <-m.ch:
463 if !ok {
464 // Close may have been called
465 return nil
466 }
467 if msg.Idx == m.nextMessageIdx {
468 m.nextMessageIdx++
469 return msg
470 }
471 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
472 panic("out of order message")
473 }
474 }
475}
476
Sean McCulloughd9d45812025-04-30 16:53:41 -0700477// Assert that Agent satisfies the CodingAgent interface.
478var _ CodingAgent = &Agent{}
479
480// StateName implements CodingAgent.
481func (a *Agent) CurrentStateName() string {
482 if a.stateMachine == nil {
483 return ""
484 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000485 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700486}
487
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700488// CurrentTodoContent returns the current todo list data as JSON.
489// It returns an empty string if no todos exist.
490func (a *Agent) CurrentTodoContent() string {
491 todoPath := claudetool.TodoFilePath(a.config.SessionID)
492 content, err := os.ReadFile(todoPath)
493 if err != nil {
494 return ""
495 }
496 return string(content)
497}
498
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700499// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
500func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
501 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.
502
503IMPORTANT: 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.
504
505Please create a detailed summary that includes:
506
5071. **User's Request**: What did the user originally ask me to do? What was their goal?
508
5092. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
510
5113. **Key Technical Decisions**: What important technical choices were made during our work and why?
512
5134. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
514
5155. **Next Steps**: What still needs to be done to complete the user's request?
516
5176. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
518
519Focus 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.
520
521Reply with ONLY the summary content - no meta-commentary about creating the summary.`
522
523 userMessage := llm.UserStringMessage(msg)
524 // Use a subconversation with history to get the summary
525 // TODO: We don't have any tools here, so we should have enough tokens
526 // to capture a summary, but we may need to modify the history (e.g., remove
527 // TODO data) to save on some tokens.
528 convo := a.convo.SubConvoWithHistory()
529
530 // Modify the system prompt to provide context about the original task
531 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000532 convo.SystemPrompt = `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.
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700533
534Your 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.
535
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000536Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700537
538 resp, err := convo.SendMessage(userMessage)
539 if err != nil {
540 a.pushToOutbox(ctx, errorMessage(err))
541 return "", err
542 }
543 textContent := collectTextContent(resp)
544
545 // Restore original system prompt (though this subconvo will be discarded)
546 convo.SystemPrompt = originalSystemPrompt
547
548 return textContent, nil
549}
550
551// CompactConversation compacts the current conversation by generating a summary
552// and restarting the conversation with that summary as the initial context
553func (a *Agent) CompactConversation(ctx context.Context) error {
554 summary, err := a.generateConversationSummary(ctx)
555 if err != nil {
556 return fmt.Errorf("failed to generate conversation summary: %w", err)
557 }
558
559 a.mu.Lock()
560
561 // Get usage information before resetting conversation
562 lastUsage := a.convo.LastUsage()
563 contextWindow := a.config.Service.TokenContextWindow()
564 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
565
566 // Reset conversation state but keep all other state (git, working dir, etc.)
567 a.firstMessageIndex = len(a.history)
568 a.convo = a.initConvo()
569
570 a.mu.Unlock()
571
572 // Create informative compaction message with token details
573 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
574 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
575 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
576
577 a.pushToOutbox(ctx, AgentMessage{
578 Type: CompactMessageType,
579 Content: compactionMsg,
580 })
581
582 a.pushToOutbox(ctx, AgentMessage{
583 Type: UserMessageType,
584 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),
585 })
586 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)
587
588 return nil
589}
590
Earl Lee2e463fb2025-04-17 11:22:22 -0700591func (a *Agent) URL() string { return a.url }
592
593// Title returns the current title of the conversation.
594// If no title has been set, returns an empty string.
595func (a *Agent) Title() string {
596 a.mu.Lock()
597 defer a.mu.Unlock()
598 return a.title
599}
600
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000601// BranchName returns the git branch name for the conversation.
602func (a *Agent) BranchName() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700603 return a.gitState.BranchName()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000604}
605
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000606// OutstandingLLMCallCount returns the number of outstanding LLM calls.
607func (a *Agent) OutstandingLLMCallCount() int {
608 a.mu.Lock()
609 defer a.mu.Unlock()
610 return len(a.outstandingLLMCalls)
611}
612
613// OutstandingToolCalls returns the names of outstanding tool calls.
614func (a *Agent) OutstandingToolCalls() []string {
615 a.mu.Lock()
616 defer a.mu.Unlock()
617
618 tools := make([]string, 0, len(a.outstandingToolCalls))
619 for _, toolName := range a.outstandingToolCalls {
620 tools = append(tools, toolName)
621 }
622 return tools
623}
624
Earl Lee2e463fb2025-04-17 11:22:22 -0700625// OS returns the operating system of the client.
626func (a *Agent) OS() string {
627 return a.config.ClientGOOS
628}
629
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000630func (a *Agent) SessionID() string {
631 return a.config.SessionID
632}
633
Philip Zeyliger18532b22025-04-23 21:11:46 +0000634// OutsideOS returns the operating system of the outside system.
635func (a *Agent) OutsideOS() string {
636 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000637}
638
Philip Zeyliger18532b22025-04-23 21:11:46 +0000639// OutsideHostname returns the hostname of the outside system.
640func (a *Agent) OutsideHostname() string {
641 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000642}
643
Philip Zeyliger18532b22025-04-23 21:11:46 +0000644// OutsideWorkingDir returns the working directory on the outside system.
645func (a *Agent) OutsideWorkingDir() string {
646 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000647}
648
649// GitOrigin returns the URL of the git remote 'origin' if it exists.
650func (a *Agent) GitOrigin() string {
651 return a.gitOrigin
652}
653
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000654func (a *Agent) OpenBrowser(url string) {
655 if !a.IsInContainer() {
656 browser.Open(url)
657 return
658 }
659 // We're in Docker, need to send a request to the Git server
660 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700661 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000662 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700663 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000664 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700665 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000666 return
667 }
668 defer resp.Body.Close()
669 if resp.StatusCode == http.StatusOK {
670 return
671 }
672 body, _ := io.ReadAll(resp.Body)
673 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
674}
675
Sean McCullough96b60dd2025-04-30 09:49:10 -0700676// CurrentState returns the current state of the agent's state machine.
677func (a *Agent) CurrentState() State {
678 return a.stateMachine.CurrentState()
679}
680
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700681func (a *Agent) IsInContainer() bool {
682 return a.config.InDocker
683}
684
685func (a *Agent) FirstMessageIndex() int {
686 a.mu.Lock()
687 defer a.mu.Unlock()
688 return a.firstMessageIndex
689}
690
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000691// SetTitle sets the title of the conversation.
692func (a *Agent) SetTitle(title string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700693 a.mu.Lock()
694 defer a.mu.Unlock()
695 a.title = title
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000696}
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700697
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +0000698// SetBranch sets the branch name of the conversation.
699func (a *Agent) SetBranch(branchName string) {
700 a.mu.Lock()
701 defer a.mu.Unlock()
Philip Zeyligerf2872992025-05-22 10:35:28 -0700702 a.gitState.SetBranchName(branchName)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000703 convo, ok := a.convo.(*conversation.Convo)
704 if ok {
705 convo.ExtraData["branch"] = branchName
706 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700707}
708
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000709// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700710func (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 +0000711 // Track the tool call
712 a.mu.Lock()
713 a.outstandingToolCalls[id] = toolName
714 a.mu.Unlock()
715}
716
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700717// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
718// If there's only one element in the array and it's a text type, it returns that text directly.
719// It also processes nested ToolResult arrays recursively.
720func contentToString(contents []llm.Content) string {
721 if len(contents) == 0 {
722 return ""
723 }
724
725 // If there's only one element and it's a text type, return it directly
726 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
727 return contents[0].Text
728 }
729
730 // Otherwise, concatenate all text content
731 var result strings.Builder
732 for _, content := range contents {
733 if content.Type == llm.ContentTypeText {
734 result.WriteString(content.Text)
735 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
736 // Recursively process nested tool results
737 result.WriteString(contentToString(content.ToolResult))
738 }
739 }
740
741 return result.String()
742}
743
Earl Lee2e463fb2025-04-17 11:22:22 -0700744// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700745func (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 +0000746 // Remove the tool call from outstanding calls
747 a.mu.Lock()
748 delete(a.outstandingToolCalls, toolID)
749 a.mu.Unlock()
750
Earl Lee2e463fb2025-04-17 11:22:22 -0700751 m := AgentMessage{
752 Type: ToolUseMessageType,
753 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700754 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700755 ToolError: content.ToolError,
756 ToolName: toolName,
757 ToolInput: string(toolInput),
758 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700759 StartTime: content.ToolUseStartTime,
760 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700761 }
762
763 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700764 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
765 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700766 m.Elapsed = &elapsed
767 }
768
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700769 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700770 a.pushToOutbox(ctx, m)
771}
772
773// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700774func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000775 a.mu.Lock()
776 defer a.mu.Unlock()
777 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700778 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
779}
780
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700781// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700782// that need to be displayed (as well as tool calls that we send along when
783// they're done). (It would be reasonable to also mention tool calls when they're
784// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700785func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000786 // Remove the LLM call from outstanding calls
787 a.mu.Lock()
788 delete(a.outstandingLLMCalls, id)
789 a.mu.Unlock()
790
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700791 if resp == nil {
792 // LLM API call failed
793 m := AgentMessage{
794 Type: ErrorMessageType,
795 Content: "API call failed, type 'continue' to try again",
796 }
797 m.SetConvo(convo)
798 a.pushToOutbox(ctx, m)
799 return
800 }
801
Earl Lee2e463fb2025-04-17 11:22:22 -0700802 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700803 if convo.Parent == nil { // subconvos never end the turn
804 switch resp.StopReason {
805 case llm.StopReasonToolUse:
806 // Check whether any of the tool calls are for tools that should end the turn
807 ToolSearch:
808 for _, part := range resp.Content {
809 if part.Type != llm.ContentTypeToolUse {
810 continue
811 }
Sean McCullough021557a2025-05-05 23:20:53 +0000812 // Find the tool by name
813 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700814 if tool.Name == part.ToolName {
815 endOfTurn = tool.EndsTurn
816 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000817 }
818 }
Sean McCullough021557a2025-05-05 23:20:53 +0000819 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700820 default:
821 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000822 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700823 }
824 m := AgentMessage{
825 Type: AgentMessageType,
826 Content: collectTextContent(resp),
827 EndOfTurn: endOfTurn,
828 Usage: &resp.Usage,
829 StartTime: resp.StartTime,
830 EndTime: resp.EndTime,
831 }
832
833 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700834 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700835 var toolCalls []ToolCall
836 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700837 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700838 toolCalls = append(toolCalls, ToolCall{
839 Name: part.ToolName,
840 Input: string(part.ToolInput),
841 ToolCallId: part.ID,
842 })
843 }
844 }
845 m.ToolCalls = toolCalls
846 }
847
848 // Calculate the elapsed time if both start and end times are set
849 if resp.StartTime != nil && resp.EndTime != nil {
850 elapsed := resp.EndTime.Sub(*resp.StartTime)
851 m.Elapsed = &elapsed
852 }
853
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700854 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700855 a.pushToOutbox(ctx, m)
856}
857
858// WorkingDir implements CodingAgent.
859func (a *Agent) WorkingDir() string {
860 return a.workingDir
861}
862
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000863// RepoRoot returns the git repository root directory.
864func (a *Agent) RepoRoot() string {
865 return a.repoRoot
866}
867
Earl Lee2e463fb2025-04-17 11:22:22 -0700868// MessageCount implements CodingAgent.
869func (a *Agent) MessageCount() int {
870 a.mu.Lock()
871 defer a.mu.Unlock()
872 return len(a.history)
873}
874
875// Messages implements CodingAgent.
876func (a *Agent) Messages(start int, end int) []AgentMessage {
877 a.mu.Lock()
878 defer a.mu.Unlock()
879 return slices.Clone(a.history[start:end])
880}
881
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700882// ShouldCompact checks if the conversation should be compacted based on token usage
883func (a *Agent) ShouldCompact() bool {
884 // Get the threshold from environment variable, default to 0.94 (94%)
885 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
886 // and a little bit of buffer.)
887 thresholdRatio := 0.94
888 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
889 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
890 thresholdRatio = parsed
891 }
892 }
893
894 // Get the most recent usage to check current context size
895 lastUsage := a.convo.LastUsage()
896
897 if lastUsage.InputTokens == 0 {
898 // No API calls made yet
899 return false
900 }
901
902 // Calculate the current context size from the last API call
903 // This includes all tokens that were part of the input context:
904 // - Input tokens (user messages, system prompt, conversation history)
905 // - Cache read tokens (cached parts of the context)
906 // - Cache creation tokens (new parts being cached)
907 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
908
909 // Get the service's token context window
910 service := a.config.Service
911 contextWindow := service.TokenContextWindow()
912
913 // Calculate threshold
914 threshold := uint64(float64(contextWindow) * thresholdRatio)
915
916 // Check if we've exceeded the threshold
917 return currentContextSize >= threshold
918}
919
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700920func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700921 return a.originalBudget
922}
923
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000924// Upstream returns the upstream branch for git work
925func (a *Agent) Upstream() string {
926 return a.gitState.Upstream()
927}
928
Earl Lee2e463fb2025-04-17 11:22:22 -0700929// AgentConfig contains configuration for creating a new Agent.
930type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +0000931 Context context.Context
932 Service llm.Service
933 Budget conversation.Budget
934 GitUsername string
935 GitEmail string
936 SessionID string
937 ClientGOOS string
938 ClientGOARCH string
939 InDocker bool
940 OneShot bool
941 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000942 // Outside information
943 OutsideHostname string
944 OutsideOS string
945 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700946
947 // Outtie's HTTP to, e.g., open a browser
948 OutsideHTTP string
949 // Outtie's Git server
950 GitRemoteAddr string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000951 // Upstream branch for git work
952 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700953 // Commit to checkout from Outtie
954 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000955 // Prefix for git branches created by sketch
956 BranchPrefix string
Earl Lee2e463fb2025-04-17 11:22:22 -0700957}
958
959// NewAgent creates a new Agent.
960// It is not usable until Init() is called.
961func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000962 // Set default branch prefix if not specified
963 if config.BranchPrefix == "" {
964 config.BranchPrefix = "sketch/"
965 }
966
Earl Lee2e463fb2025-04-17 11:22:22 -0700967 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -0700968 config: config,
969 ready: make(chan struct{}),
970 inbox: make(chan string, 100),
971 subscribers: make([]chan *AgentMessage, 0),
972 startedAt: time.Now(),
973 originalBudget: config.Budget,
974 gitState: AgentGitState{
975 seenCommits: make(map[string]bool),
976 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000977 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -0700978 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000979 outsideHostname: config.OutsideHostname,
980 outsideOS: config.OutsideOS,
981 outsideWorkingDir: config.OutsideWorkingDir,
982 outstandingLLMCalls: make(map[string]struct{}),
983 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -0700984 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700985 workingDir: config.WorkingDir,
986 outsideHTTP: config.OutsideHTTP,
Sean McCullough364f7412025-06-02 00:55:44 +0000987 portMonitor: NewPortMonitor(),
Earl Lee2e463fb2025-04-17 11:22:22 -0700988 }
989 return agent
990}
991
992type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700993 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -0700994
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700995 InDocker bool
996 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -0700997}
998
999func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001000 if a.convo != nil {
1001 return fmt.Errorf("Agent.Init: already initialized")
1002 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001003 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001004 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001005
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001006 if !ini.NoGit {
1007 // Capture the original origin before we potentially replace it below
1008 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
1009 }
1010
Philip Zeyliger222bf412025-06-04 16:42:58 +00001011 // If a remote git addr was specified, we configure the origin remote
Philip Zeyligerf2872992025-05-22 10:35:28 -07001012 if a.gitState.gitRemoteAddr != "" {
1013 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
Philip Zeyliger222bf412025-06-04 16:42:58 +00001014
1015 // Remove existing origin remote if it exists
1016 cmd := exec.CommandContext(ctx, "git", "remote", "remove", "origin")
Philip Zeyligerf2872992025-05-22 10:35:28 -07001017 cmd.Dir = a.workingDir
1018 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001019 // Ignore error if origin doesn't exist
1020 slog.DebugContext(ctx, "git remote remove origin (ignoring if not exists)", slog.String("output", string(out)))
Philip Zeyligerf2872992025-05-22 10:35:28 -07001021 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001022
1023 // Add the new remote as origin
1024 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", a.gitState.gitRemoteAddr)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001025 cmd.Dir = a.workingDir
1026 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001027 return fmt.Errorf("git remote add origin: %s: %v", out, err)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001028 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001029
Philip Zeyligerf2872992025-05-22 10:35:28 -07001030 }
1031
1032 // If a commit was specified, we fetch and reset to it.
1033 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001034 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1035
Earl Lee2e463fb2025-04-17 11:22:22 -07001036 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001037 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001038 if out, err := cmd.CombinedOutput(); err != nil {
1039 return fmt.Errorf("git stash: %s: %v", out, err)
1040 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001041 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001042 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001043 if out, err := cmd.CombinedOutput(); err != nil {
1044 return fmt.Errorf("git fetch: %s: %w", out, err)
1045 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001046 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1047 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001048 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1049 // Remove git hooks if they exist and retry
1050 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001051 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001052 if _, statErr := os.Stat(hookPath); statErr == nil {
1053 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1054 slog.String("error", err.Error()),
1055 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001056 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001057 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1058 }
1059
1060 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001061 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1062 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001063 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001064 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 +01001065 }
1066 } else {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001067 return fmt.Errorf("git checkout %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001068 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001069 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001070 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001071
1072 if ini.HostAddr != "" {
1073 a.url = "http://" + ini.HostAddr
1074 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001075
1076 if !ini.NoGit {
1077 repoRoot, err := repoRoot(ctx, a.workingDir)
1078 if err != nil {
1079 return fmt.Errorf("repoRoot: %w", err)
1080 }
1081 a.repoRoot = repoRoot
1082
Earl Lee2e463fb2025-04-17 11:22:22 -07001083 if err != nil {
1084 return fmt.Errorf("resolveRef: %w", err)
1085 }
Philip Zeyliger49edc922025-05-14 09:45:45 -07001086
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001087 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001088 if err := setupGitHooks(a.repoRoot); err != nil {
1089 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1090 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001091 }
1092
Philip Zeyliger49edc922025-05-14 09:45:45 -07001093 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1094 cmd.Dir = repoRoot
1095 if out, err := cmd.CombinedOutput(); err != nil {
1096 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1097 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001098
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001099 slog.Info("running codebase analysis")
1100 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1101 if err != nil {
1102 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001103 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001104 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001105
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001106 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001107 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001108 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001109 }
1110 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001111
Earl Lee2e463fb2025-04-17 11:22:22 -07001112 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001113 a.gitState.lastHEAD = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001114 a.convo = a.initConvo()
1115 close(a.ready)
1116 return nil
1117}
1118
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001119//go:embed agent_system_prompt.txt
1120var agentSystemPrompt string
1121
Earl Lee2e463fb2025-04-17 11:22:22 -07001122// initConvo initializes the conversation.
1123// It must not be called until all agent fields are initialized,
1124// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001125func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001126 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001127 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -07001128 convo.PromptCaching = true
1129 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001130 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001131 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001132
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001133 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1134 bashPermissionCheck := func(command string) error {
1135 // Check if branch name is set
1136 a.mu.Lock()
Philip Zeyligerf2872992025-05-22 10:35:28 -07001137 branchSet := a.gitState.BranchName() != ""
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001138 a.mu.Unlock()
1139
1140 // If branch is set, all commands are allowed
1141 if branchSet {
1142 return nil
1143 }
1144
1145 // If branch is not set, check if this is a git commit command
1146 willCommit, err := bashkit.WillRunGitCommit(command)
1147 if err != nil {
1148 // If there's an error checking, we should allow the command to proceed
1149 return nil
1150 }
1151
1152 // If it's a git commit and branch is not set, return an error
1153 if willCommit {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001154 return fmt.Errorf("you must use the precommit tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001155 }
1156
1157 return nil
1158 }
1159
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001160 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001161
Earl Lee2e463fb2025-04-17 11:22:22 -07001162 // Register all tools with the conversation
1163 // When adding, removing, or modifying tools here, double-check that the termui tool display
1164 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001165
1166 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001167 _, supportsScreenshots := a.config.Service.(*ant.Service)
1168 var bTools []*llm.Tool
1169 var browserCleanup func()
1170
1171 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1172 // Add cleanup function to context cancel
1173 go func() {
1174 <-a.config.Context.Done()
1175 browserCleanup()
1176 }()
1177 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001178
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001179 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001180 bashTool, claudetool.Keyword, claudetool.Patch,
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001181 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.titleTool(), a.precommitTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001182 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001183 }
1184
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001185 // One-shot mode is non-interactive, multiple choice requires human response
1186 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001187 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001188 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001189
1190 convo.Tools = append(convo.Tools, browserTools...)
Earl Lee2e463fb2025-04-17 11:22:22 -07001191 convo.Listener = a
1192 return convo
1193}
1194
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001195var multipleChoiceTool = &llm.Tool{
1196 Name: "multiplechoice",
1197 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.",
1198 EndsTurn: true,
1199 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001200 "type": "object",
1201 "description": "The question and a list of answers you would expect the user to choose from.",
1202 "properties": {
1203 "question": {
1204 "type": "string",
1205 "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?'"
1206 },
1207 "responseOptions": {
1208 "type": "array",
1209 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1210 "items": {
1211 "type": "object",
1212 "properties": {
1213 "caption": {
1214 "type": "string",
1215 "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'"
1216 },
1217 "responseText": {
1218 "type": "string",
1219 "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'"
1220 }
1221 },
1222 "required": ["caption", "responseText"]
1223 }
1224 }
1225 },
1226 "required": ["question", "responseOptions"]
1227}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001228 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1229 // The Run logic for "multiplechoice" tool is a no-op on the server.
1230 // The UI will present a list of options for the user to select from,
1231 // and that's it as far as "executing" the tool_use goes.
1232 // When the user *does* select one of the presented options, that
1233 // responseText gets sent as a chat message on behalf of the user.
1234 return llm.TextContent("end your turn and wait for the user to respond"), nil
1235 },
Sean McCullough485afc62025-04-28 14:28:39 -07001236}
1237
1238type MultipleChoiceOption struct {
1239 Caption string `json:"caption"`
1240 ResponseText string `json:"responseText"`
1241}
1242
1243type MultipleChoiceParams struct {
1244 Question string `json:"question"`
1245 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1246}
1247
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001248// branchExists reports whether branchName exists, either locally or in well-known remotes.
1249func branchExists(dir, branchName string) bool {
1250 refs := []string{
1251 "refs/heads/",
1252 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001253 }
1254 for _, ref := range refs {
1255 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1256 cmd.Dir = dir
1257 if cmd.Run() == nil { // exit code 0 means branch exists
1258 return true
1259 }
1260 }
1261 return false
1262}
1263
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001264func (a *Agent) titleTool() *llm.Tool {
1265 description := `Sets the conversation title.`
1266 titleTool := &llm.Tool{
Josh Bleecher Snyder36a5cc12025-05-05 17:59:53 -07001267 Name: "title",
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001268 Description: description,
Earl Lee2e463fb2025-04-17 11:22:22 -07001269 InputSchema: json.RawMessage(`{
1270 "type": "object",
1271 "properties": {
1272 "title": {
1273 "type": "string",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001274 "description": "Brief title (3-6 words) in imperative tense. Focus on core action/component."
Earl Lee2e463fb2025-04-17 11:22:22 -07001275 }
1276 },
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001277 "required": ["title"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001278}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001279 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001280 var params struct {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001281 Title string `json:"title"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001282 }
1283 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001284 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001285 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001286
1287 // We don't allow changing the title once set to be consistent with the previous behavior
1288 // and to prevent accidental title changes
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001289 t := a.Title()
1290 if t != "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001291 return nil, fmt.Errorf("title already set to: %s", t)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001292 }
1293
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001294 if params.Title == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001295 return nil, fmt.Errorf("title parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001296 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001297
1298 a.SetTitle(params.Title)
1299 response := fmt.Sprintf("Title set to %q", params.Title)
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001300 return llm.TextContent(response), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001301 },
1302 }
1303 return titleTool
1304}
1305
1306func (a *Agent) precommitTool() *llm.Tool {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001307 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 +00001308 preCommit := &llm.Tool{
1309 Name: "precommit",
1310 Description: description,
1311 InputSchema: json.RawMessage(`{
1312 "type": "object",
1313 "properties": {
1314 "branch_name": {
1315 "type": "string",
1316 "description": "A 2-3 word alphanumeric hyphenated slug for the git branch name"
1317 }
1318 },
1319 "required": ["branch_name"]
1320}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001321 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001322 var params struct {
1323 BranchName string `json:"branch_name"`
1324 }
1325 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001326 return nil, err
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001327 }
1328
1329 b := a.BranchName()
1330 if b != "" {
Josh Bleecher Snyder44d1f1a2025-05-12 19:18:32 -07001331 return nil, fmt.Errorf("branch already set to %s; do not create a new branch", b)
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001332 }
1333
1334 if params.BranchName == "" {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001335 return nil, fmt.Errorf("branch_name must not be empty")
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001336 }
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001337 if params.BranchName != cleanBranchName(params.BranchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001338 return nil, fmt.Errorf("branch_name parameter must be alphanumeric hyphenated slug")
Josh Bleecher Snyder42f7a7c2025-04-30 10:29:21 -07001339 }
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001340 branchName := a.config.BranchPrefix + params.BranchName
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001341 if branchExists(a.workingDir, branchName) {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001342 return nil, fmt.Errorf("branch %q already exists; please choose a different branch name", branchName)
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001343 }
1344
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001345 a.SetBranch(branchName)
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001346 response := fmt.Sprintf("switched to branch %q - DO NOT change branches unless explicitly requested", branchName)
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001347
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001348 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1349 if err != nil {
1350 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1351 }
1352 if len(styleHint) > 0 {
1353 response += "\n\n" + styleHint
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001354 }
1355
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001356 return llm.TextContent(response), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001357 },
1358 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001359 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001360}
1361
1362func (a *Agent) Ready() <-chan struct{} {
1363 return a.ready
1364}
1365
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001366// BranchPrefix returns the configured branch prefix
1367func (a *Agent) BranchPrefix() string {
1368 return a.config.BranchPrefix
1369}
1370
Earl Lee2e463fb2025-04-17 11:22:22 -07001371func (a *Agent) UserMessage(ctx context.Context, msg string) {
1372 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1373 a.inbox <- msg
1374}
1375
Earl Lee2e463fb2025-04-17 11:22:22 -07001376func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1377 return a.convo.CancelToolUse(toolUseID, cause)
1378}
1379
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001380func (a *Agent) CancelTurn(cause error) {
1381 a.cancelTurnMu.Lock()
1382 defer a.cancelTurnMu.Unlock()
1383 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001384 // Force state transition to cancelled state
1385 ctx := a.config.Context
1386 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001387 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001388 }
1389}
1390
1391func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001392 // Start port monitoring when the agent loop begins
1393 // Only monitor ports when running in a container
1394 if a.IsInContainer() {
1395 a.portMonitor.Start(ctxOuter)
1396 }
1397
Earl Lee2e463fb2025-04-17 11:22:22 -07001398 for {
1399 select {
1400 case <-ctxOuter.Done():
1401 return
1402 default:
1403 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001404 a.cancelTurnMu.Lock()
1405 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001406 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001407 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001408 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001409 a.cancelTurn = cancel
1410 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001411 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1412 if err != nil {
1413 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1414 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001415 cancel(nil)
1416 }
1417 }
1418}
1419
1420func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1421 if m.Timestamp.IsZero() {
1422 m.Timestamp = time.Now()
1423 }
1424
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001425 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1426 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1427 m.Content = m.ToolResult
1428 }
1429
Earl Lee2e463fb2025-04-17 11:22:22 -07001430 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1431 if m.EndOfTurn && m.Type == AgentMessageType {
1432 turnDuration := time.Since(a.startOfTurn)
1433 m.TurnDuration = &turnDuration
1434 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1435 }
1436
Earl Lee2e463fb2025-04-17 11:22:22 -07001437 a.mu.Lock()
1438 defer a.mu.Unlock()
1439 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001440 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001441 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001442
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001443 // Notify all subscribers
1444 for _, ch := range a.subscribers {
1445 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001446 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001447}
1448
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001449func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1450 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001451 if block {
1452 select {
1453 case <-ctx.Done():
1454 return m, ctx.Err()
1455 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001456 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001457 }
1458 }
1459 for {
1460 select {
1461 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001462 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001463 default:
1464 return m, nil
1465 }
1466 }
1467}
1468
Sean McCullough885a16a2025-04-30 02:49:25 +00001469// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001470func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001471 // Reset the start of turn time
1472 a.startOfTurn = time.Now()
1473
Sean McCullough96b60dd2025-04-30 09:49:10 -07001474 // Transition to waiting for user input state
1475 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1476
Sean McCullough885a16a2025-04-30 02:49:25 +00001477 // Process initial user message
1478 initialResp, err := a.processUserMessage(ctx)
1479 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001480 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001481 return err
1482 }
1483
1484 // Handle edge case where both initialResp and err are nil
1485 if initialResp == nil {
1486 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001487 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1488
Sean McCullough9f4b8082025-04-30 17:34:07 +00001489 a.pushToOutbox(ctx, errorMessage(err))
1490 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001491 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001492
Earl Lee2e463fb2025-04-17 11:22:22 -07001493 // We do this as we go, but let's also do it at the end of the turn
1494 defer func() {
1495 if _, err := a.handleGitCommits(ctx); err != nil {
1496 // Just log the error, don't stop execution
1497 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1498 }
1499 }()
1500
Sean McCullougha1e0e492025-05-01 10:51:08 -07001501 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001502 resp := initialResp
1503 for {
1504 // Check if we are over budget
1505 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001506 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001507 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001508 }
1509
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001510 // Check if we should compact the conversation
1511 if a.ShouldCompact() {
1512 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1513 if err := a.CompactConversation(ctx); err != nil {
1514 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1515 return err
1516 }
1517 // After compaction, end this turn and start fresh
1518 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1519 return nil
1520 }
1521
Sean McCullough885a16a2025-04-30 02:49:25 +00001522 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001523 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001524 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001525 break
1526 }
1527
Sean McCullough96b60dd2025-04-30 09:49:10 -07001528 // Transition to tool use requested state
1529 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1530
Sean McCullough885a16a2025-04-30 02:49:25 +00001531 // Handle tool execution
1532 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1533 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001534 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001535 }
1536
Sean McCullougha1e0e492025-05-01 10:51:08 -07001537 if toolResp == nil {
1538 return fmt.Errorf("cannot continue conversation with a nil tool response")
1539 }
1540
Sean McCullough885a16a2025-04-30 02:49:25 +00001541 // Set the response for the next iteration
1542 resp = toolResp
1543 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001544
1545 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001546}
1547
1548// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001549func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001550 // Wait for at least one message from the user
1551 msgs, err := a.GatherMessages(ctx, true)
1552 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001553 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001554 return nil, err
1555 }
1556
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001557 userMessage := llm.Message{
1558 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001559 Content: msgs,
1560 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001561
Sean McCullough96b60dd2025-04-30 09:49:10 -07001562 // Transition to sending to LLM state
1563 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1564
Sean McCullough885a16a2025-04-30 02:49:25 +00001565 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001566 resp, err := a.convo.SendMessage(userMessage)
1567 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001568 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001569 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001570 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001571 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001572
Sean McCullough96b60dd2025-04-30 09:49:10 -07001573 // Transition to processing LLM response state
1574 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1575
Sean McCullough885a16a2025-04-30 02:49:25 +00001576 return resp, nil
1577}
1578
1579// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001580func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1581 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001582 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001583 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001584
Sean McCullough96b60dd2025-04-30 09:49:10 -07001585 // Transition to checking for cancellation state
1586 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1587
Sean McCullough885a16a2025-04-30 02:49:25 +00001588 // Check if the operation was cancelled by the user
1589 select {
1590 case <-ctx.Done():
1591 // Don't actually run any of the tools, but rather build a response
1592 // for each tool_use message letting the LLM know that user canceled it.
1593 var err error
1594 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001595 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001596 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001597 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001598 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001599 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001600 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001601 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001602 // Transition to running tool state
1603 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1604
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001605 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001606 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001607 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001608
1609 // Execute the tools
1610 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001611 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001612 if ctx.Err() != nil { // e.g. the user canceled the operation
1613 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001614 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001615 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001616 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001617 a.pushToOutbox(ctx, errorMessage(err))
1618 }
1619 }
1620
1621 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001622 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001623 autoqualityMessages := a.processGitChanges(ctx)
1624
1625 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001626 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001627 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001628 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001629 return false, nil
1630 }
1631
1632 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001633 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1634 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001635}
1636
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001637// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001638func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001639 // Check for git commits
1640 _, err := a.handleGitCommits(ctx)
1641 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001642 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001643 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001644 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001645 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001646}
1647
1648// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1649// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001650func (a *Agent) processGitChanges(ctx context.Context) []string {
1651 // Check for git commits after tool execution
1652 newCommits, err := a.handleGitCommits(ctx)
1653 if err != nil {
1654 // Just log the error, don't stop execution
1655 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1656 return nil
1657 }
1658
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001659 // Run mechanical checks if there was exactly one new commit.
1660 if len(newCommits) != 1 {
1661 return nil
1662 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001663 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001664 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1665 msg := a.codereview.RunMechanicalChecks(ctx)
1666 if msg != "" {
1667 a.pushToOutbox(ctx, AgentMessage{
1668 Type: AutoMessageType,
1669 Content: msg,
1670 Timestamp: time.Now(),
1671 })
1672 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001673 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001674
1675 return autoqualityMessages
1676}
1677
1678// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001679func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001680 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001681 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001682 msgs, err := a.GatherMessages(ctx, false)
1683 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001684 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001685 return false, nil
1686 }
1687
1688 // Inject any auto-generated messages from quality checks
1689 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001690 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001691 }
1692
1693 // Handle cancellation by appending a message about it
1694 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001695 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001696 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001697 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001698 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1699 } else if err := a.convo.OverBudget(); err != nil {
1700 // Handle budget issues by appending a message about it
1701 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 -07001702 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001703 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1704 }
1705
1706 // Combine tool results with user messages
1707 results = append(results, msgs...)
1708
1709 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001710 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001711 resp, err := a.convo.SendMessage(llm.Message{
1712 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001713 Content: results,
1714 })
1715 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001716 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001717 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1718 return true, nil // Return true to continue the conversation, but with no response
1719 }
1720
Sean McCullough96b60dd2025-04-30 09:49:10 -07001721 // Transition back to processing LLM response
1722 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1723
Sean McCullough885a16a2025-04-30 02:49:25 +00001724 if cancelled {
1725 return false, nil
1726 }
1727
1728 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001729}
1730
1731func (a *Agent) overBudget(ctx context.Context) error {
1732 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001733 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001734 m := budgetMessage(err)
1735 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001736 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001737 a.convo.ResetBudget(a.originalBudget)
1738 return err
1739 }
1740 return nil
1741}
1742
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001743func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001744 // Collect all text content
1745 var allText strings.Builder
1746 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001747 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001748 if allText.Len() > 0 {
1749 allText.WriteString("\n\n")
1750 }
1751 allText.WriteString(content.Text)
1752 }
1753 }
1754 return allText.String()
1755}
1756
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001757func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001758 a.mu.Lock()
1759 defer a.mu.Unlock()
1760 return a.convo.CumulativeUsage()
1761}
1762
Earl Lee2e463fb2025-04-17 11:22:22 -07001763// Diff returns a unified diff of changes made since the agent was instantiated.
1764func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001765 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001766 return "", fmt.Errorf("no initial commit reference available")
1767 }
1768
1769 // Find the repository root
1770 ctx := context.Background()
1771
1772 // If a specific commit hash is provided, show just that commit's changes
1773 if commit != nil && *commit != "" {
1774 // Validate that the commit looks like a valid git SHA
1775 if !isValidGitSHA(*commit) {
1776 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1777 }
1778
1779 // Get the diff for just this commit
1780 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1781 cmd.Dir = a.repoRoot
1782 output, err := cmd.CombinedOutput()
1783 if err != nil {
1784 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1785 }
1786 return string(output), nil
1787 }
1788
1789 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001790 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001791 cmd.Dir = a.repoRoot
1792 output, err := cmd.CombinedOutput()
1793 if err != nil {
1794 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1795 }
1796
1797 return string(output), nil
1798}
1799
Philip Zeyliger49edc922025-05-14 09:45:45 -07001800// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1801// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1802func (a *Agent) SketchGitBaseRef() string {
1803 if a.IsInContainer() {
1804 return "sketch-base"
1805 } else {
1806 return "sketch-base-" + a.SessionID()
1807 }
1808}
1809
1810// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1811func (a *Agent) SketchGitBase() string {
1812 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1813 cmd.Dir = a.repoRoot
1814 output, err := cmd.CombinedOutput()
1815 if err != nil {
1816 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1817 return "HEAD"
1818 }
1819 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001820}
1821
Pokey Rule7a113622025-05-12 10:58:45 +01001822// removeGitHooks removes the Git hooks directory from the repository
1823func removeGitHooks(_ context.Context, repoPath string) error {
1824 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1825
1826 // Check if hooks directory exists
1827 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1828 // Directory doesn't exist, nothing to do
1829 return nil
1830 }
1831
1832 // Remove the hooks directory
1833 err := os.RemoveAll(hooksDir)
1834 if err != nil {
1835 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1836 }
1837
1838 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001839 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001840 if err != nil {
1841 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1842 }
1843
1844 return nil
1845}
1846
Philip Zeyligerf2872992025-05-22 10:35:28 -07001847func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001848 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001849 for _, msg := range msgs {
1850 a.pushToOutbox(ctx, msg)
1851 }
1852 return commits, error
1853}
1854
Earl Lee2e463fb2025-04-17 11:22:22 -07001855// handleGitCommits() highlights new commits to the user. When running
1856// under docker, new HEADs are pushed to a branch according to the title.
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001857func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001858 ags.mu.Lock()
1859 defer ags.mu.Unlock()
1860
1861 msgs := []AgentMessage{}
1862 if repoRoot == "" {
1863 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001864 }
1865
Philip Zeyligerf2872992025-05-22 10:35:28 -07001866 head, err := resolveRef(ctx, repoRoot, "HEAD")
Earl Lee2e463fb2025-04-17 11:22:22 -07001867 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001868 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001869 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001870 if head == ags.lastHEAD {
1871 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001872 }
1873 defer func() {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001874 ags.lastHEAD = head
Earl Lee2e463fb2025-04-17 11:22:22 -07001875 }()
1876
1877 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1878 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1879 // to the last 100 commits.
1880 var commits []*GitCommit
1881
1882 // Get commits since the initial commit
1883 // Format: <hash>\0<subject>\0<body>\0
1884 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1885 // Limit to 100 commits to avoid overwhelming the user
Philip Zeyligerf2872992025-05-22 10:35:28 -07001886 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, head)
1887 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001888 output, err := cmd.Output()
1889 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001890 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001891 }
1892
1893 // Parse git log output and filter out already seen commits
1894 parsedCommits := parseGitLog(string(output))
1895
1896 var headCommit *GitCommit
1897
1898 // Filter out commits we've already seen
1899 for _, commit := range parsedCommits {
1900 if commit.Hash == head {
1901 headCommit = &commit
1902 }
1903
1904 // Skip if we've seen this commit before. If our head has changed, always include that.
Philip Zeyligerf2872992025-05-22 10:35:28 -07001905 if ags.seenCommits[commit.Hash] && commit.Hash != head {
Earl Lee2e463fb2025-04-17 11:22:22 -07001906 continue
1907 }
1908
1909 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001910 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001911
1912 // Add to our list of new commits
1913 commits = append(commits, &commit)
1914 }
1915
Philip Zeyligerf2872992025-05-22 10:35:28 -07001916 if ags.gitRemoteAddr != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001917 if headCommit == nil {
1918 // I think this can only happen if we have a bug or if there's a race.
1919 headCommit = &GitCommit{}
1920 headCommit.Hash = head
1921 headCommit.Subject = "unknown"
1922 commits = append(commits, headCommit)
1923 }
1924
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001925 originalBranch := cmp.Or(ags.branchName, branchPrefix+sessionID)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001926 branch := originalBranch
Earl Lee2e463fb2025-04-17 11:22:22 -07001927
1928 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1929 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1930 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001931
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001932 // Parse the original branch name to extract base name and starting number
1933 baseBranch, startNum := parseBranchNameAndNumber(originalBranch)
1934
Philip Zeyliger113e2052025-05-09 21:59:40 +00001935 // Try up to 10 times with different branch names if the branch is checked out on the remote
1936 var out []byte
1937 var err error
1938 for retries := range 10 {
1939 if retries > 0 {
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001940 // Increment from the starting number (foo1->foo2, foo2->foo3, etc.)
1941 branch = fmt.Sprintf("%s%d", baseBranch, startNum+retries)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001942 }
1943
Philip Zeyligerf2872992025-05-22 10:35:28 -07001944 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "HEAD:refs/heads/"+branch)
1945 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001946 out, err = cmd.CombinedOutput()
1947
1948 if err == nil {
1949 // Success! Break out of the retry loop
1950 break
1951 }
1952
1953 // Check if this is the "refusing to update checked out branch" error
1954 if !strings.Contains(string(out), "refusing to update checked out branch") {
1955 // This is a different error, so don't retry
1956 break
1957 }
1958
1959 // If we're on the last retry, we'll report the error
1960 if retries == 9 {
1961 break
1962 }
1963 }
1964
1965 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001966 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001967 } else {
1968 headCommit.PushedBranch = branch
Philip Zeyliger113e2052025-05-09 21:59:40 +00001969 // Update the agent's branch name if we ended up using a different one
1970 if branch != originalBranch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001971 ags.branchName = branch
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001972 // Notify user why the branch name was changed
1973 msgs = append(msgs, AgentMessage{
1974 Type: AutoMessageType,
1975 Timestamp: time.Now(),
1976 Content: fmt.Sprintf("Branch renamed from %s to %s because the original branch is currently checked out on the remote.", originalBranch, branch),
1977 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00001978 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001979 }
1980 }
1981
1982 // If we found new commits, create a message
1983 if len(commits) > 0 {
1984 msg := AgentMessage{
1985 Type: CommitMessageType,
1986 Timestamp: time.Now(),
1987 Commits: commits,
1988 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001989 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001990 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001991 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001992}
1993
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001994func cleanBranchName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001995 return strings.Map(func(r rune) rune {
1996 // lowercase
1997 if r >= 'A' && r <= 'Z' {
1998 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001999 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002000 // replace spaces with dashes
2001 if r == ' ' {
2002 return '-'
2003 }
2004 // allow alphanumerics and dashes
2005 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2006 return r
2007 }
2008 return -1
2009 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002010}
2011
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002012// parseBranchNameAndNumber extracts the base branch name and starting number.
2013// For "sketch/foo1" returns ("sketch/foo", 1)
2014// For "sketch/foo" returns ("sketch/foo", 0)
2015func parseBranchNameAndNumber(branchName string) (baseBranch string, startNum int) {
2016 re := regexp.MustCompile(`^(.+?)(\d+)$`)
2017 matches := re.FindStringSubmatch(branchName)
2018
2019 if len(matches) != 3 {
2020 // No trailing digits found
2021 return branchName, 0
2022 }
2023
2024 num, err := strconv.Atoi(matches[2])
2025 if err != nil {
2026 // If parsing fails, treat as no number
2027 return branchName, 0
2028 }
2029
2030 return matches[1], num
2031}
2032
Earl Lee2e463fb2025-04-17 11:22:22 -07002033// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2034// and returns an array of GitCommit structs.
2035func parseGitLog(output string) []GitCommit {
2036 var commits []GitCommit
2037
2038 // No output means no commits
2039 if len(output) == 0 {
2040 return commits
2041 }
2042
2043 // Split by NULL byte
2044 parts := strings.Split(output, "\x00")
2045
2046 // Process in triplets (hash, subject, body)
2047 for i := 0; i < len(parts); i++ {
2048 // Skip empty parts
2049 if parts[i] == "" {
2050 continue
2051 }
2052
2053 // This should be a hash
2054 hash := strings.TrimSpace(parts[i])
2055
2056 // Make sure we have at least a subject part available
2057 if i+1 >= len(parts) {
2058 break // No more parts available
2059 }
2060
2061 // Get the subject
2062 subject := strings.TrimSpace(parts[i+1])
2063
2064 // Get the body if available
2065 body := ""
2066 if i+2 < len(parts) {
2067 body = strings.TrimSpace(parts[i+2])
2068 }
2069
2070 // Skip to the next triplet
2071 i += 2
2072
2073 commits = append(commits, GitCommit{
2074 Hash: hash,
2075 Subject: subject,
2076 Body: body,
2077 })
2078 }
2079
2080 return commits
2081}
2082
2083func repoRoot(ctx context.Context, dir string) (string, error) {
2084 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2085 stderr := new(strings.Builder)
2086 cmd.Stderr = stderr
2087 cmd.Dir = dir
2088 out, err := cmd.Output()
2089 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002090 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002091 }
2092 return strings.TrimSpace(string(out)), nil
2093}
2094
2095func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2096 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2097 stderr := new(strings.Builder)
2098 cmd.Stderr = stderr
2099 cmd.Dir = dir
2100 out, err := cmd.Output()
2101 if err != nil {
2102 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2103 }
2104 // TODO: validate that out is valid hex
2105 return strings.TrimSpace(string(out)), nil
2106}
2107
2108// isValidGitSHA validates if a string looks like a valid git SHA hash.
2109// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2110func isValidGitSHA(sha string) bool {
2111 // Git SHA must be a hexadecimal string with at least 4 characters
2112 if len(sha) < 4 || len(sha) > 40 {
2113 return false
2114 }
2115
2116 // Check if the string only contains hexadecimal characters
2117 for _, char := range sha {
2118 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2119 return false
2120 }
2121 }
2122
2123 return true
2124}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002125
2126// getGitOrigin returns the URL of the git remote 'origin' if it exists
2127func getGitOrigin(ctx context.Context, dir string) string {
2128 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2129 cmd.Dir = dir
2130 stderr := new(strings.Builder)
2131 cmd.Stderr = stderr
2132 out, err := cmd.Output()
2133 if err != nil {
2134 return ""
2135 }
2136 return strings.TrimSpace(string(out))
2137}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002138
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002139// systemPromptData contains the data used to render the system prompt template
2140type systemPromptData struct {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002141 ClientGOOS string
2142 ClientGOARCH string
2143 WorkingDir string
2144 RepoRoot string
2145 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002146 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002147}
2148
2149// renderSystemPrompt renders the system prompt template.
2150func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002151 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002152 ClientGOOS: a.config.ClientGOOS,
2153 ClientGOARCH: a.config.ClientGOARCH,
2154 WorkingDir: a.workingDir,
2155 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002156 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002157 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002158 }
2159
2160 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2161 if err != nil {
2162 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2163 }
2164 buf := new(strings.Builder)
2165 err = tmpl.Execute(buf, data)
2166 if err != nil {
2167 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2168 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002169 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002170 return buf.String()
2171}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002172
2173// StateTransitionIterator provides an iterator over state transitions.
2174type StateTransitionIterator interface {
2175 // Next blocks until a new state transition is available or context is done.
2176 // Returns nil if the context is cancelled.
2177 Next() *StateTransition
2178 // Close removes the listener and cleans up resources.
2179 Close()
2180}
2181
2182// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2183type StateTransitionIteratorImpl struct {
2184 agent *Agent
2185 ctx context.Context
2186 ch chan StateTransition
2187 unsubscribe func()
2188}
2189
2190// Next blocks until a new state transition is available or the context is cancelled.
2191func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2192 select {
2193 case <-s.ctx.Done():
2194 return nil
2195 case transition, ok := <-s.ch:
2196 if !ok {
2197 return nil
2198 }
2199 transitionCopy := transition
2200 return &transitionCopy
2201 }
2202}
2203
2204// Close removes the listener and cleans up resources.
2205func (s *StateTransitionIteratorImpl) Close() {
2206 if s.unsubscribe != nil {
2207 s.unsubscribe()
2208 s.unsubscribe = nil
2209 }
2210}
2211
2212// NewStateTransitionIterator returns an iterator that receives state transitions.
2213func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2214 a.mu.Lock()
2215 defer a.mu.Unlock()
2216
2217 // Create channel to receive state transitions
2218 ch := make(chan StateTransition, 10)
2219
2220 // Add a listener to the state machine
2221 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2222
2223 return &StateTransitionIteratorImpl{
2224 agent: a,
2225 ctx: ctx,
2226 ch: ch,
2227 unsubscribe: unsubscribe,
2228 }
2229}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002230
2231// setupGitHooks creates or updates git hooks in the specified working directory.
2232func setupGitHooks(workingDir string) error {
2233 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2234
2235 _, err := os.Stat(hooksDir)
2236 if os.IsNotExist(err) {
2237 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2238 }
2239 if err != nil {
2240 return fmt.Errorf("error checking git hooks directory: %w", err)
2241 }
2242
2243 // Define the post-commit hook content
2244 postCommitHook := `#!/bin/bash
2245echo "<post_commit_hook>"
2246echo "Please review this commit message and fix it if it is incorrect."
2247echo "This hook only echos the commit message; it does not modify it."
2248echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2249echo "<last_commit_message>"
2250git log -1 --pretty=%B
2251echo "</last_commit_message>"
2252echo "</post_commit_hook>"
2253`
2254
2255 // Define the prepare-commit-msg hook content
2256 prepareCommitMsgHook := `#!/bin/bash
2257# Add Co-Authored-By and Change-ID trailers to commit messages
2258# Check if these trailers already exist before adding them
2259
2260commit_file="$1"
2261COMMIT_SOURCE="$2"
2262
2263# Skip for merges, squashes, or when using a commit template
2264if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2265 [ "$COMMIT_SOURCE" = "squash" ]; then
2266 exit 0
2267fi
2268
2269commit_msg=$(cat "$commit_file")
2270
2271needs_co_author=true
2272needs_change_id=true
2273
2274# Check if commit message already has Co-Authored-By trailer
2275if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2276 needs_co_author=false
2277fi
2278
2279# Check if commit message already has Change-ID trailer
2280if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2281 needs_change_id=false
2282fi
2283
2284# Only modify if at least one trailer needs to be added
2285if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002286 # Ensure there's a proper blank line before trailers
2287 if [ -s "$commit_file" ]; then
2288 # Check if file ends with newline by reading last character
2289 last_char=$(tail -c 1 "$commit_file")
2290
2291 if [ "$last_char" != "" ]; then
2292 # File doesn't end with newline - add two newlines (complete line + blank line)
2293 echo "" >> "$commit_file"
2294 echo "" >> "$commit_file"
2295 else
2296 # File ends with newline - check if we already have a blank line
2297 last_line=$(tail -1 "$commit_file")
2298 if [ -n "$last_line" ]; then
2299 # Last line has content - add one newline for blank line
2300 echo "" >> "$commit_file"
2301 fi
2302 # If last line is empty, we already have a blank line - don't add anything
2303 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002304 fi
2305
2306 # Add trailers if needed
2307 if [ "$needs_co_author" = true ]; then
2308 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2309 fi
2310
2311 if [ "$needs_change_id" = true ]; then
2312 change_id=$(openssl rand -hex 8)
2313 echo "Change-ID: s${change_id}k" >> "$commit_file"
2314 fi
2315fi
2316`
2317
2318 // Update or create the post-commit hook
2319 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2320 if err != nil {
2321 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2322 }
2323
2324 // Update or create the prepare-commit-msg hook
2325 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2326 if err != nil {
2327 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2328 }
2329
2330 return nil
2331}
2332
2333// updateOrCreateHook creates a new hook file or updates an existing one
2334// by appending the new content if it doesn't already contain it.
2335func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2336 // Check if the hook already exists
2337 buf, err := os.ReadFile(hookPath)
2338 if os.IsNotExist(err) {
2339 // Hook doesn't exist, create it
2340 err = os.WriteFile(hookPath, []byte(content), 0o755)
2341 if err != nil {
2342 return fmt.Errorf("failed to create hook: %w", err)
2343 }
2344 return nil
2345 }
2346 if err != nil {
2347 return fmt.Errorf("error reading existing hook: %w", err)
2348 }
2349
2350 // Hook exists, check if our content is already in it by looking for a distinctive line
2351 code := string(buf)
2352 if strings.Contains(code, distinctiveLine) {
2353 // Already contains our content, nothing to do
2354 return nil
2355 }
2356
2357 // Append our content to the existing hook
2358 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2359 if err != nil {
2360 return fmt.Errorf("failed to open hook for appending: %w", err)
2361 }
2362 defer f.Close()
2363
2364 // Ensure there's a newline at the end of the existing content if needed
2365 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2366 _, err = f.WriteString("\n")
2367 if err != nil {
2368 return fmt.Errorf("failed to add newline to hook: %w", err)
2369 }
2370 }
2371
2372 // Add a separator before our content
2373 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2374 if err != nil {
2375 return fmt.Errorf("failed to append to hook: %w", err)
2376 }
2377
2378 return nil
2379}
Sean McCullough138ec242025-06-02 22:42:06 +00002380
2381// GetPortMonitor returns the port monitor instance for accessing port events
2382func (a *Agent) GetPortMonitor() *PortMonitor {
2383 return a.portMonitor
2384}