blob: 0cfc35b7b0ab4ebad03f9e90c0a2e783c1f238ca [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package loop
2
3import (
4 "context"
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07005 _ "embed"
Earl Lee2e463fb2025-04-17 11:22:22 -07006 "encoding/json"
7 "fmt"
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +00008 "io"
Earl Lee2e463fb2025-04-17 11:22:22 -07009 "log/slog"
10 "net/http"
11 "os"
12 "os/exec"
Pokey Rule7a113622025-05-12 10:58:45 +010013 "path/filepath"
Earl Lee2e463fb2025-04-17 11:22:22 -070014 "runtime/debug"
15 "slices"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070016 "strconv"
Earl Lee2e463fb2025-04-17 11:22:22 -070017 "strings"
18 "sync"
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +000019 "text/template"
Earl Lee2e463fb2025-04-17 11:22:22 -070020 "time"
21
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +000022 "sketch.dev/browser"
Earl Lee2e463fb2025-04-17 11:22:22 -070023 "sketch.dev/claudetool"
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +000024 "sketch.dev/claudetool/bashkit"
Autoformatter4962f152025-05-06 17:24:20 +000025 "sketch.dev/claudetool/browse"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000026 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydera997be62025-05-07 22:52:46 +000027 "sketch.dev/claudetool/onstart"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070028 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070029 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070030 "sketch.dev/llm/conversation"
Philip Zeyligerc17ffe32025-06-05 19:49:13 -070031 "sketch.dev/skabandclient"
Earl Lee2e463fb2025-04-17 11:22:22 -070032)
33
34const (
35 userCancelMessage = "user requested agent to stop handling responses"
36)
37
Philip Zeyligerb7c58752025-05-01 10:10:17 -070038type MessageIterator interface {
39 // Next blocks until the next message is available. It may
40 // return nil if the underlying iterator context is done.
41 Next() *AgentMessage
42 Close()
43}
44
Earl Lee2e463fb2025-04-17 11:22:22 -070045type CodingAgent interface {
46 // Init initializes an agent inside a docker container.
47 Init(AgentInit) error
48
49 // Ready returns a channel closed after Init successfully called.
50 Ready() <-chan struct{}
51
52 // URL reports the HTTP URL of this agent.
53 URL() string
54
55 // UserMessage enqueues a message to the agent and returns immediately.
56 UserMessage(ctx context.Context, msg string)
57
Philip Zeyligerb7c58752025-05-01 10:10:17 -070058 // Returns an iterator that finishes when the context is done and
59 // starts with the given message index.
60 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070061
Philip Zeyligereab12de2025-05-14 02:35:53 +000062 // Returns an iterator that notifies of state transitions until the context is done.
63 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
64
Earl Lee2e463fb2025-04-17 11:22:22 -070065 // Loop begins the agent loop returns only when ctx is cancelled.
66 Loop(ctx context.Context)
67
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000068 // BranchPrefix returns the configured branch prefix
69 BranchPrefix() string
70
Sean McCulloughedc88dc2025-04-30 02:55:01 +000071 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070072
73 CancelToolUse(toolUseID string, cause error) error
74
75 // Returns a subset of the agent's message history.
76 Messages(start int, end int) []AgentMessage
77
78 // Returns the current number of messages in the history
79 MessageCount() int
80
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070081 TotalUsage() conversation.CumulativeUsage
82 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070083
Earl Lee2e463fb2025-04-17 11:22:22 -070084 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000085 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070086
87 // Diff returns a unified diff of changes made since the agent was instantiated.
88 // If commit is non-nil, it shows the diff for just that specific commit.
89 Diff(commit *string) (string, error)
90
Philip Zeyliger49edc922025-05-14 09:45:45 -070091 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
92 // starts out as the commit where sketch started, but a user can move it if need
93 // be, for example in the case of a rebase. It is stored as a git tag.
94 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070095
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000096 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
97 // (Typically, this is "sketch-base")
98 SketchGitBaseRef() string
99
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700100 // Slug returns the slug identifier for this session.
101 Slug() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700102
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000103 // BranchName returns the git branch name for the conversation.
104 BranchName() string
105
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700106 // IncrementRetryNumber increments the retry number for branch naming conflicts.
107 IncrementRetryNumber()
108
Earl Lee2e463fb2025-04-17 11:22:22 -0700109 // OS returns the operating system of the client.
110 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000111
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000112 // SessionID returns the unique session identifier.
113 SessionID() string
114
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000115 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700116 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000117
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000118 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
119 OutstandingLLMCallCount() int
120
121 // OutstandingToolCalls returns the names of outstanding tool calls.
122 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000123 OutsideOS() string
124 OutsideHostname() string
125 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000126 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000127 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
128 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700129
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700130 // IsInContainer returns true if the agent is running in a container
131 IsInContainer() bool
132 // FirstMessageIndex returns the index of the first message in the current conversation
133 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700134
135 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700136 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
137 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700138
139 // CompactConversation compacts the current conversation by generating a summary
140 // and restarting the conversation with that summary as the initial context
141 CompactConversation(ctx context.Context) error
Sean McCullough138ec242025-06-02 22:42:06 +0000142 // GetPortMonitor returns the port monitor instance for accessing port events
143 GetPortMonitor() *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700144}
145
146type CodingAgentMessageType string
147
148const (
149 UserMessageType CodingAgentMessageType = "user"
150 AgentMessageType CodingAgentMessageType = "agent"
151 ErrorMessageType CodingAgentMessageType = "error"
152 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
153 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700154 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
155 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
156 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700157
158 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
159)
160
161type AgentMessage struct {
162 Type CodingAgentMessageType `json:"type"`
163 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
164 EndOfTurn bool `json:"end_of_turn"`
165
166 Content string `json:"content"`
167 ToolName string `json:"tool_name,omitempty"`
168 ToolInput string `json:"input,omitempty"`
169 ToolResult string `json:"tool_result,omitempty"`
170 ToolError bool `json:"tool_error,omitempty"`
171 ToolCallId string `json:"tool_call_id,omitempty"`
172
173 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
174 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
175
Sean McCulloughd9f13372025-04-21 15:08:49 -0700176 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
177 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
178
Earl Lee2e463fb2025-04-17 11:22:22 -0700179 // Commits is a list of git commits for a commit message
180 Commits []*GitCommit `json:"commits,omitempty"`
181
182 Timestamp time.Time `json:"timestamp"`
183 ConversationID string `json:"conversation_id"`
184 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700185 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700186
187 // Message timing information
188 StartTime *time.Time `json:"start_time,omitempty"`
189 EndTime *time.Time `json:"end_time,omitempty"`
190 Elapsed *time.Duration `json:"elapsed,omitempty"`
191
192 // Turn duration - the time taken for a complete agent turn
193 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
194
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000195 // HideOutput indicates that this message should not be rendered in the UI.
196 // This is useful for subconversations that generate output that shouldn't be shown to the user.
197 HideOutput bool `json:"hide_output,omitempty"`
198
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700199 // TodoContent contains the agent's todo file content when it has changed
200 TodoContent *string `json:"todo_content,omitempty"`
201
Earl Lee2e463fb2025-04-17 11:22:22 -0700202 Idx int `json:"idx"`
203}
204
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000205// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700206func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700207 if convo == nil {
208 m.ConversationID = ""
209 m.ParentConversationID = nil
210 return
211 }
212 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000213 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700214 if convo.Parent != nil {
215 m.ParentConversationID = &convo.Parent.ID
216 }
217}
218
Earl Lee2e463fb2025-04-17 11:22:22 -0700219// GitCommit represents a single git commit for a commit message
220type GitCommit struct {
221 Hash string `json:"hash"` // Full commit hash
222 Subject string `json:"subject"` // Commit subject line
223 Body string `json:"body"` // Full commit message body
224 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
225}
226
227// ToolCall represents a single tool call within an agent message
228type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700229 Name string `json:"name"`
230 Input string `json:"input"`
231 ToolCallId string `json:"tool_call_id"`
232 ResultMessage *AgentMessage `json:"result_message,omitempty"`
233 Args string `json:"args,omitempty"`
234 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700235}
236
237func (a *AgentMessage) Attr() slog.Attr {
238 var attrs []any = []any{
239 slog.String("type", string(a.Type)),
240 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700241 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700242 if a.EndOfTurn {
243 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
244 }
245 if a.Content != "" {
246 attrs = append(attrs, slog.String("content", a.Content))
247 }
248 if a.ToolName != "" {
249 attrs = append(attrs, slog.String("tool_name", a.ToolName))
250 }
251 if a.ToolInput != "" {
252 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
253 }
254 if a.Elapsed != nil {
255 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
256 }
257 if a.TurnDuration != nil {
258 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
259 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700260 if len(a.ToolResult) > 0 {
261 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700262 }
263 if a.ToolError {
264 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
265 }
266 if len(a.ToolCalls) > 0 {
267 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
268 for i, tc := range a.ToolCalls {
269 toolCallAttrs = append(toolCallAttrs, slog.Group(
270 fmt.Sprintf("tool_call_%d", i),
271 slog.String("name", tc.Name),
272 slog.String("input", tc.Input),
273 ))
274 }
275 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
276 }
277 if a.ConversationID != "" {
278 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
279 }
280 if a.ParentConversationID != nil {
281 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
282 }
283 if a.Usage != nil && !a.Usage.IsZero() {
284 attrs = append(attrs, a.Usage.Attr())
285 }
286 // TODO: timestamp, convo ids, idx?
287 return slog.Group("agent_message", attrs...)
288}
289
290func errorMessage(err error) AgentMessage {
291 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
292 if os.Getenv(("DEBUG")) == "1" {
293 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
294 }
295
296 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
297}
298
299func budgetMessage(err error) AgentMessage {
300 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
301}
302
303// ConvoInterface defines the interface for conversation interactions
304type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700305 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700306 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700307 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700308 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700309 SendMessage(message llm.Message) (*llm.Response, error)
310 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700311 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000312 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700313 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700314 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700315 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700316}
317
Philip Zeyligerf2872992025-05-22 10:35:28 -0700318// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700319// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700320// any time we notice we need to.
321type AgentGitState struct {
322 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700323 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700324 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000325 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700326 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700327 slug string // Human-readable session identifier
328 retryNumber int // Number to append when branch conflicts occur
Philip Zeyligerf2872992025-05-22 10:35:28 -0700329}
330
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700331func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700332 ags.mu.Lock()
333 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700334 if ags.slug != slug {
335 ags.retryNumber = 0
336 }
337 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700338}
339
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700340func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700341 ags.mu.Lock()
342 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700343 return ags.slug
344}
345
346func (ags *AgentGitState) IncrementRetryNumber() {
347 ags.mu.Lock()
348 defer ags.mu.Unlock()
349 ags.retryNumber++
350}
351
352// HasSeenCommits returns true if any commits have been processed
353func (ags *AgentGitState) HasSeenCommits() bool {
354 ags.mu.Lock()
355 defer ags.mu.Unlock()
356 return len(ags.seenCommits) > 0
357}
358
359func (ags *AgentGitState) RetryNumber() int {
360 ags.mu.Lock()
361 defer ags.mu.Unlock()
362 return ags.retryNumber
363}
364
365func (ags *AgentGitState) BranchName(prefix string) string {
366 ags.mu.Lock()
367 defer ags.mu.Unlock()
368 return ags.branchNameLocked(prefix)
369}
370
371func (ags *AgentGitState) branchNameLocked(prefix string) string {
372 if ags.slug == "" {
373 return ""
374 }
375 if ags.retryNumber == 0 {
376 return prefix + ags.slug
377 }
378 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700379}
380
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000381func (ags *AgentGitState) Upstream() string {
382 ags.mu.Lock()
383 defer ags.mu.Unlock()
384 return ags.upstream
385}
386
Earl Lee2e463fb2025-04-17 11:22:22 -0700387type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700388 convo ConvoInterface
389 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700390 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700391 workingDir string
392 repoRoot string // workingDir may be a subdir of repoRoot
393 url string
394 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000395 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700396 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000397 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700398 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700399 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000400 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700401 // State machine to track agent state
402 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000403 // Outside information
404 outsideHostname string
405 outsideOS string
406 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000407 // URL of the git remote 'origin' if it exists
408 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700409
410 // Time when the current turn started (reset at the beginning of InnerLoop)
411 startOfTurn time.Time
412
413 // Inbox - for messages from the user to the agent.
414 // sent on by UserMessage
415 // . e.g. when user types into the chat textarea
416 // read from by GatherMessages
417 inbox chan string
418
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000419 // protects cancelTurn
420 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700421 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000422 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700423
424 // protects following
425 mu sync.Mutex
426
427 // Stores all messages for this agent
428 history []AgentMessage
429
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700430 // Iterators add themselves here when they're ready to be notified of new messages.
431 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700432
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000433 // Track outstanding LLM call IDs
434 outstandingLLMCalls map[string]struct{}
435
436 // Track outstanding tool calls by ID with their names
437 outstandingToolCalls map[string]string
Sean McCullough364f7412025-06-02 00:55:44 +0000438
439 // Port monitoring
440 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700441}
442
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700443// NewIterator implements CodingAgent.
444func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
445 a.mu.Lock()
446 defer a.mu.Unlock()
447
448 return &MessageIteratorImpl{
449 agent: a,
450 ctx: ctx,
451 nextMessageIdx: nextMessageIdx,
452 ch: make(chan *AgentMessage, 100),
453 }
454}
455
456type MessageIteratorImpl struct {
457 agent *Agent
458 ctx context.Context
459 nextMessageIdx int
460 ch chan *AgentMessage
461 subscribed bool
462}
463
464func (m *MessageIteratorImpl) Close() {
465 m.agent.mu.Lock()
466 defer m.agent.mu.Unlock()
467 // Delete ourselves from the subscribers list
468 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
469 return x == m.ch
470 })
471 close(m.ch)
472}
473
474func (m *MessageIteratorImpl) Next() *AgentMessage {
475 // We avoid subscription at creation to let ourselves catch up to "current state"
476 // before subscribing.
477 if !m.subscribed {
478 m.agent.mu.Lock()
479 if m.nextMessageIdx < len(m.agent.history) {
480 msg := &m.agent.history[m.nextMessageIdx]
481 m.nextMessageIdx++
482 m.agent.mu.Unlock()
483 return msg
484 }
485 // The next message doesn't exist yet, so let's subscribe
486 m.agent.subscribers = append(m.agent.subscribers, m.ch)
487 m.subscribed = true
488 m.agent.mu.Unlock()
489 }
490
491 for {
492 select {
493 case <-m.ctx.Done():
494 m.agent.mu.Lock()
495 // Delete ourselves from the subscribers list
496 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
497 return x == m.ch
498 })
499 m.subscribed = false
500 m.agent.mu.Unlock()
501 return nil
502 case msg, ok := <-m.ch:
503 if !ok {
504 // Close may have been called
505 return nil
506 }
507 if msg.Idx == m.nextMessageIdx {
508 m.nextMessageIdx++
509 return msg
510 }
511 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
512 panic("out of order message")
513 }
514 }
515}
516
Sean McCulloughd9d45812025-04-30 16:53:41 -0700517// Assert that Agent satisfies the CodingAgent interface.
518var _ CodingAgent = &Agent{}
519
520// StateName implements CodingAgent.
521func (a *Agent) CurrentStateName() string {
522 if a.stateMachine == nil {
523 return ""
524 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000525 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700526}
527
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700528// CurrentTodoContent returns the current todo list data as JSON.
529// It returns an empty string if no todos exist.
530func (a *Agent) CurrentTodoContent() string {
531 todoPath := claudetool.TodoFilePath(a.config.SessionID)
532 content, err := os.ReadFile(todoPath)
533 if err != nil {
534 return ""
535 }
536 return string(content)
537}
538
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700539// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
540func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
541 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.
542
543IMPORTANT: 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.
544
545Please create a detailed summary that includes:
546
5471. **User's Request**: What did the user originally ask me to do? What was their goal?
548
5492. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
550
5513. **Key Technical Decisions**: What important technical choices were made during our work and why?
552
5534. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
554
5555. **Next Steps**: What still needs to be done to complete the user's request?
556
5576. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
558
559Focus 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.
560
561Reply with ONLY the summary content - no meta-commentary about creating the summary.`
562
563 userMessage := llm.UserStringMessage(msg)
564 // Use a subconversation with history to get the summary
565 // TODO: We don't have any tools here, so we should have enough tokens
566 // to capture a summary, but we may need to modify the history (e.g., remove
567 // TODO data) to save on some tokens.
568 convo := a.convo.SubConvoWithHistory()
569
570 // Modify the system prompt to provide context about the original task
571 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000572 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 -0700573
574Your 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.
575
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000576Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700577
578 resp, err := convo.SendMessage(userMessage)
579 if err != nil {
580 a.pushToOutbox(ctx, errorMessage(err))
581 return "", err
582 }
583 textContent := collectTextContent(resp)
584
585 // Restore original system prompt (though this subconvo will be discarded)
586 convo.SystemPrompt = originalSystemPrompt
587
588 return textContent, nil
589}
590
591// CompactConversation compacts the current conversation by generating a summary
592// and restarting the conversation with that summary as the initial context
593func (a *Agent) CompactConversation(ctx context.Context) error {
594 summary, err := a.generateConversationSummary(ctx)
595 if err != nil {
596 return fmt.Errorf("failed to generate conversation summary: %w", err)
597 }
598
599 a.mu.Lock()
600
601 // Get usage information before resetting conversation
602 lastUsage := a.convo.LastUsage()
603 contextWindow := a.config.Service.TokenContextWindow()
604 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
605
606 // Reset conversation state but keep all other state (git, working dir, etc.)
607 a.firstMessageIndex = len(a.history)
608 a.convo = a.initConvo()
609
610 a.mu.Unlock()
611
612 // Create informative compaction message with token details
613 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
614 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
615 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
616
617 a.pushToOutbox(ctx, AgentMessage{
618 Type: CompactMessageType,
619 Content: compactionMsg,
620 })
621
622 a.pushToOutbox(ctx, AgentMessage{
623 Type: UserMessageType,
624 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),
625 })
626 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)
627
628 return nil
629}
630
Earl Lee2e463fb2025-04-17 11:22:22 -0700631func (a *Agent) URL() string { return a.url }
632
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000633// BranchName returns the git branch name for the conversation.
634func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700635 return a.gitState.BranchName(a.config.BranchPrefix)
636}
637
638// Slug returns the slug identifier for this conversation.
639func (a *Agent) Slug() string {
640 return a.gitState.Slug()
641}
642
643// IncrementRetryNumber increments the retry number for branch naming conflicts
644func (a *Agent) IncrementRetryNumber() {
645 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000646}
647
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000648// OutstandingLLMCallCount returns the number of outstanding LLM calls.
649func (a *Agent) OutstandingLLMCallCount() int {
650 a.mu.Lock()
651 defer a.mu.Unlock()
652 return len(a.outstandingLLMCalls)
653}
654
655// OutstandingToolCalls returns the names of outstanding tool calls.
656func (a *Agent) OutstandingToolCalls() []string {
657 a.mu.Lock()
658 defer a.mu.Unlock()
659
660 tools := make([]string, 0, len(a.outstandingToolCalls))
661 for _, toolName := range a.outstandingToolCalls {
662 tools = append(tools, toolName)
663 }
664 return tools
665}
666
Earl Lee2e463fb2025-04-17 11:22:22 -0700667// OS returns the operating system of the client.
668func (a *Agent) OS() string {
669 return a.config.ClientGOOS
670}
671
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000672func (a *Agent) SessionID() string {
673 return a.config.SessionID
674}
675
Philip Zeyliger18532b22025-04-23 21:11:46 +0000676// OutsideOS returns the operating system of the outside system.
677func (a *Agent) OutsideOS() string {
678 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000679}
680
Philip Zeyliger18532b22025-04-23 21:11:46 +0000681// OutsideHostname returns the hostname of the outside system.
682func (a *Agent) OutsideHostname() string {
683 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000684}
685
Philip Zeyliger18532b22025-04-23 21:11:46 +0000686// OutsideWorkingDir returns the working directory on the outside system.
687func (a *Agent) OutsideWorkingDir() string {
688 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000689}
690
691// GitOrigin returns the URL of the git remote 'origin' if it exists.
692func (a *Agent) GitOrigin() string {
693 return a.gitOrigin
694}
695
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000696func (a *Agent) OpenBrowser(url string) {
697 if !a.IsInContainer() {
698 browser.Open(url)
699 return
700 }
701 // We're in Docker, need to send a request to the Git server
702 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700703 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000704 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700705 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000706 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700707 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000708 return
709 }
710 defer resp.Body.Close()
711 if resp.StatusCode == http.StatusOK {
712 return
713 }
714 body, _ := io.ReadAll(resp.Body)
715 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
716}
717
Sean McCullough96b60dd2025-04-30 09:49:10 -0700718// CurrentState returns the current state of the agent's state machine.
719func (a *Agent) CurrentState() State {
720 return a.stateMachine.CurrentState()
721}
722
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700723func (a *Agent) IsInContainer() bool {
724 return a.config.InDocker
725}
726
727func (a *Agent) FirstMessageIndex() int {
728 a.mu.Lock()
729 defer a.mu.Unlock()
730 return a.firstMessageIndex
731}
732
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700733// SetSlug sets a human-readable identifier for the conversation.
734func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700735 a.mu.Lock()
736 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700737
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700738 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000739 convo, ok := a.convo.(*conversation.Convo)
740 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700741 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000742 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700743}
744
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000745// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700746func (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 +0000747 // Track the tool call
748 a.mu.Lock()
749 a.outstandingToolCalls[id] = toolName
750 a.mu.Unlock()
751}
752
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700753// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
754// If there's only one element in the array and it's a text type, it returns that text directly.
755// It also processes nested ToolResult arrays recursively.
756func contentToString(contents []llm.Content) string {
757 if len(contents) == 0 {
758 return ""
759 }
760
761 // If there's only one element and it's a text type, return it directly
762 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
763 return contents[0].Text
764 }
765
766 // Otherwise, concatenate all text content
767 var result strings.Builder
768 for _, content := range contents {
769 if content.Type == llm.ContentTypeText {
770 result.WriteString(content.Text)
771 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
772 // Recursively process nested tool results
773 result.WriteString(contentToString(content.ToolResult))
774 }
775 }
776
777 return result.String()
778}
779
Earl Lee2e463fb2025-04-17 11:22:22 -0700780// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700781func (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 +0000782 // Remove the tool call from outstanding calls
783 a.mu.Lock()
784 delete(a.outstandingToolCalls, toolID)
785 a.mu.Unlock()
786
Earl Lee2e463fb2025-04-17 11:22:22 -0700787 m := AgentMessage{
788 Type: ToolUseMessageType,
789 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700790 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700791 ToolError: content.ToolError,
792 ToolName: toolName,
793 ToolInput: string(toolInput),
794 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700795 StartTime: content.ToolUseStartTime,
796 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700797 }
798
799 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700800 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
801 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700802 m.Elapsed = &elapsed
803 }
804
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700805 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700806 a.pushToOutbox(ctx, m)
807}
808
809// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700810func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000811 a.mu.Lock()
812 defer a.mu.Unlock()
813 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700814 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
815}
816
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700817// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700818// that need to be displayed (as well as tool calls that we send along when
819// they're done). (It would be reasonable to also mention tool calls when they're
820// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700821func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000822 // Remove the LLM call from outstanding calls
823 a.mu.Lock()
824 delete(a.outstandingLLMCalls, id)
825 a.mu.Unlock()
826
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700827 if resp == nil {
828 // LLM API call failed
829 m := AgentMessage{
830 Type: ErrorMessageType,
831 Content: "API call failed, type 'continue' to try again",
832 }
833 m.SetConvo(convo)
834 a.pushToOutbox(ctx, m)
835 return
836 }
837
Earl Lee2e463fb2025-04-17 11:22:22 -0700838 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700839 if convo.Parent == nil { // subconvos never end the turn
840 switch resp.StopReason {
841 case llm.StopReasonToolUse:
842 // Check whether any of the tool calls are for tools that should end the turn
843 ToolSearch:
844 for _, part := range resp.Content {
845 if part.Type != llm.ContentTypeToolUse {
846 continue
847 }
Sean McCullough021557a2025-05-05 23:20:53 +0000848 // Find the tool by name
849 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700850 if tool.Name == part.ToolName {
851 endOfTurn = tool.EndsTurn
852 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000853 }
854 }
Sean McCullough021557a2025-05-05 23:20:53 +0000855 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700856 default:
857 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000858 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700859 }
860 m := AgentMessage{
861 Type: AgentMessageType,
862 Content: collectTextContent(resp),
863 EndOfTurn: endOfTurn,
864 Usage: &resp.Usage,
865 StartTime: resp.StartTime,
866 EndTime: resp.EndTime,
867 }
868
869 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700870 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700871 var toolCalls []ToolCall
872 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700873 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700874 toolCalls = append(toolCalls, ToolCall{
875 Name: part.ToolName,
876 Input: string(part.ToolInput),
877 ToolCallId: part.ID,
878 })
879 }
880 }
881 m.ToolCalls = toolCalls
882 }
883
884 // Calculate the elapsed time if both start and end times are set
885 if resp.StartTime != nil && resp.EndTime != nil {
886 elapsed := resp.EndTime.Sub(*resp.StartTime)
887 m.Elapsed = &elapsed
888 }
889
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700890 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700891 a.pushToOutbox(ctx, m)
892}
893
894// WorkingDir implements CodingAgent.
895func (a *Agent) WorkingDir() string {
896 return a.workingDir
897}
898
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000899// RepoRoot returns the git repository root directory.
900func (a *Agent) RepoRoot() string {
901 return a.repoRoot
902}
903
Earl Lee2e463fb2025-04-17 11:22:22 -0700904// MessageCount implements CodingAgent.
905func (a *Agent) MessageCount() int {
906 a.mu.Lock()
907 defer a.mu.Unlock()
908 return len(a.history)
909}
910
911// Messages implements CodingAgent.
912func (a *Agent) Messages(start int, end int) []AgentMessage {
913 a.mu.Lock()
914 defer a.mu.Unlock()
915 return slices.Clone(a.history[start:end])
916}
917
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700918// ShouldCompact checks if the conversation should be compacted based on token usage
919func (a *Agent) ShouldCompact() bool {
920 // Get the threshold from environment variable, default to 0.94 (94%)
921 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
922 // and a little bit of buffer.)
923 thresholdRatio := 0.94
924 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
925 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
926 thresholdRatio = parsed
927 }
928 }
929
930 // Get the most recent usage to check current context size
931 lastUsage := a.convo.LastUsage()
932
933 if lastUsage.InputTokens == 0 {
934 // No API calls made yet
935 return false
936 }
937
938 // Calculate the current context size from the last API call
939 // This includes all tokens that were part of the input context:
940 // - Input tokens (user messages, system prompt, conversation history)
941 // - Cache read tokens (cached parts of the context)
942 // - Cache creation tokens (new parts being cached)
943 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
944
945 // Get the service's token context window
946 service := a.config.Service
947 contextWindow := service.TokenContextWindow()
948
949 // Calculate threshold
950 threshold := uint64(float64(contextWindow) * thresholdRatio)
951
952 // Check if we've exceeded the threshold
953 return currentContextSize >= threshold
954}
955
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700956func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700957 return a.originalBudget
958}
959
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000960// Upstream returns the upstream branch for git work
961func (a *Agent) Upstream() string {
962 return a.gitState.Upstream()
963}
964
Earl Lee2e463fb2025-04-17 11:22:22 -0700965// AgentConfig contains configuration for creating a new Agent.
966type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +0000967 Context context.Context
968 Service llm.Service
969 Budget conversation.Budget
970 GitUsername string
971 GitEmail string
972 SessionID string
973 ClientGOOS string
974 ClientGOARCH string
975 InDocker bool
976 OneShot bool
977 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000978 // Outside information
979 OutsideHostname string
980 OutsideOS string
981 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700982
983 // Outtie's HTTP to, e.g., open a browser
984 OutsideHTTP string
985 // Outtie's Git server
986 GitRemoteAddr string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000987 // Upstream branch for git work
988 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700989 // Commit to checkout from Outtie
990 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +0000991 // Prefix for git branches created by sketch
992 BranchPrefix string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -0700993 // Skaband client for session history (optional)
994 SkabandClient *skabandclient.SkabandClient
Earl Lee2e463fb2025-04-17 11:22:22 -0700995}
996
997// NewAgent creates a new Agent.
998// It is not usable until Init() is called.
999func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001000 // Set default branch prefix if not specified
1001 if config.BranchPrefix == "" {
1002 config.BranchPrefix = "sketch/"
1003 }
1004
Earl Lee2e463fb2025-04-17 11:22:22 -07001005 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001006 config: config,
1007 ready: make(chan struct{}),
1008 inbox: make(chan string, 100),
1009 subscribers: make([]chan *AgentMessage, 0),
1010 startedAt: time.Now(),
1011 originalBudget: config.Budget,
1012 gitState: AgentGitState{
1013 seenCommits: make(map[string]bool),
1014 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001015 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001016 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001017 outsideHostname: config.OutsideHostname,
1018 outsideOS: config.OutsideOS,
1019 outsideWorkingDir: config.OutsideWorkingDir,
1020 outstandingLLMCalls: make(map[string]struct{}),
1021 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001022 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001023 workingDir: config.WorkingDir,
1024 outsideHTTP: config.OutsideHTTP,
Sean McCullough364f7412025-06-02 00:55:44 +00001025 portMonitor: NewPortMonitor(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001026 }
1027 return agent
1028}
1029
1030type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001031 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001032
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001033 InDocker bool
1034 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001035}
1036
1037func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001038 if a.convo != nil {
1039 return fmt.Errorf("Agent.Init: already initialized")
1040 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001041 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001042 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001043
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001044 if !ini.NoGit {
1045 // Capture the original origin before we potentially replace it below
1046 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
1047 }
1048
Philip Zeyliger222bf412025-06-04 16:42:58 +00001049 // If a remote git addr was specified, we configure the origin remote
Philip Zeyligerf2872992025-05-22 10:35:28 -07001050 if a.gitState.gitRemoteAddr != "" {
1051 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
Philip Zeyliger222bf412025-06-04 16:42:58 +00001052
1053 // Remove existing origin remote if it exists
1054 cmd := exec.CommandContext(ctx, "git", "remote", "remove", "origin")
Philip Zeyligerf2872992025-05-22 10:35:28 -07001055 cmd.Dir = a.workingDir
1056 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001057 // Ignore error if origin doesn't exist
1058 slog.DebugContext(ctx, "git remote remove origin (ignoring if not exists)", slog.String("output", string(out)))
Philip Zeyligerf2872992025-05-22 10:35:28 -07001059 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001060
1061 // Add the new remote as origin
1062 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", a.gitState.gitRemoteAddr)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001063 cmd.Dir = a.workingDir
1064 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001065 return fmt.Errorf("git remote add origin: %s: %v", out, err)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001066 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001067
Philip Zeyligerf2872992025-05-22 10:35:28 -07001068 }
1069
1070 // If a commit was specified, we fetch and reset to it.
1071 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001072 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1073
Earl Lee2e463fb2025-04-17 11:22:22 -07001074 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001075 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001076 if out, err := cmd.CombinedOutput(); err != nil {
1077 return fmt.Errorf("git stash: %s: %v", out, err)
1078 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001079 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001080 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001081 if out, err := cmd.CombinedOutput(); err != nil {
1082 return fmt.Errorf("git fetch: %s: %w", out, err)
1083 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001084 // The -B resets the branch if it already exists (or creates it if it doesn't)
1085 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001086 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001087 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1088 // Remove git hooks if they exist and retry
1089 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001090 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001091 if _, statErr := os.Stat(hookPath); statErr == nil {
1092 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1093 slog.String("error", err.Error()),
1094 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001095 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001096 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1097 }
1098
1099 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001100 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1101 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001102 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001103 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 +01001104 }
1105 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001106 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001107 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001108 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001109 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001110
1111 if ini.HostAddr != "" {
1112 a.url = "http://" + ini.HostAddr
1113 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001114
1115 if !ini.NoGit {
1116 repoRoot, err := repoRoot(ctx, a.workingDir)
1117 if err != nil {
1118 return fmt.Errorf("repoRoot: %w", err)
1119 }
1120 a.repoRoot = repoRoot
1121
Earl Lee2e463fb2025-04-17 11:22:22 -07001122 if err != nil {
1123 return fmt.Errorf("resolveRef: %w", err)
1124 }
Philip Zeyliger49edc922025-05-14 09:45:45 -07001125
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001126 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001127 if err := setupGitHooks(a.repoRoot); err != nil {
1128 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1129 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001130 }
1131
Philip Zeyliger49edc922025-05-14 09:45:45 -07001132 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1133 cmd.Dir = repoRoot
1134 if out, err := cmd.CombinedOutput(); err != nil {
1135 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1136 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001137
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001138 slog.Info("running codebase analysis")
1139 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1140 if err != nil {
1141 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001142 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001143 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001144
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001145 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001146 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001147 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001148 }
1149 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001150
Earl Lee2e463fb2025-04-17 11:22:22 -07001151 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001152 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001153 a.convo = a.initConvo()
1154 close(a.ready)
1155 return nil
1156}
1157
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001158//go:embed agent_system_prompt.txt
1159var agentSystemPrompt string
1160
Earl Lee2e463fb2025-04-17 11:22:22 -07001161// initConvo initializes the conversation.
1162// It must not be called until all agent fields are initialized,
1163// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001164func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001165 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001166 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -07001167 convo.PromptCaching = true
1168 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001169 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001170 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001171
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001172 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1173 bashPermissionCheck := func(command string) error {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001174 if a.gitState.Slug() != "" {
1175 return nil // branch is set up
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001176 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001177 willCommit, err := bashkit.WillRunGitCommit(command)
1178 if err != nil {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001179 return nil // fail open
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001180 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001181 if willCommit {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001182 return fmt.Errorf("you must use the set-slug tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001183 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001184 return nil
1185 }
1186
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001187 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001188
Earl Lee2e463fb2025-04-17 11:22:22 -07001189 // Register all tools with the conversation
1190 // When adding, removing, or modifying tools here, double-check that the termui tool display
1191 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001192
1193 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001194 _, supportsScreenshots := a.config.Service.(*ant.Service)
1195 var bTools []*llm.Tool
1196 var browserCleanup func()
1197
1198 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1199 // Add cleanup function to context cancel
1200 go func() {
1201 <-a.config.Context.Done()
1202 browserCleanup()
1203 }()
1204 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001205
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001206 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001207 bashTool, claudetool.Keyword, claudetool.Patch,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001208 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.setSlugTool(), a.commitMessageStyleTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001209 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001210 }
1211
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001212 // One-shot mode is non-interactive, multiple choice requires human response
1213 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001214 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001215 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001216
1217 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001218
1219 // Add session history tools if skaband client is available
1220 if a.config.SkabandClient != nil {
1221 sessionHistoryTools := claudetool.CreateSessionHistoryTools(a.config.SkabandClient, a.config.SessionID, a.gitOrigin)
1222 convo.Tools = append(convo.Tools, sessionHistoryTools...)
1223 }
1224
Earl Lee2e463fb2025-04-17 11:22:22 -07001225 convo.Listener = a
1226 return convo
1227}
1228
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001229var multipleChoiceTool = &llm.Tool{
1230 Name: "multiplechoice",
1231 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.",
1232 EndsTurn: true,
1233 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001234 "type": "object",
1235 "description": "The question and a list of answers you would expect the user to choose from.",
1236 "properties": {
1237 "question": {
1238 "type": "string",
1239 "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?'"
1240 },
1241 "responseOptions": {
1242 "type": "array",
1243 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1244 "items": {
1245 "type": "object",
1246 "properties": {
1247 "caption": {
1248 "type": "string",
1249 "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'"
1250 },
1251 "responseText": {
1252 "type": "string",
1253 "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'"
1254 }
1255 },
1256 "required": ["caption", "responseText"]
1257 }
1258 }
1259 },
1260 "required": ["question", "responseOptions"]
1261}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001262 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1263 // The Run logic for "multiplechoice" tool is a no-op on the server.
1264 // The UI will present a list of options for the user to select from,
1265 // and that's it as far as "executing" the tool_use goes.
1266 // When the user *does* select one of the presented options, that
1267 // responseText gets sent as a chat message on behalf of the user.
1268 return llm.TextContent("end your turn and wait for the user to respond"), nil
1269 },
Sean McCullough485afc62025-04-28 14:28:39 -07001270}
1271
1272type MultipleChoiceOption struct {
1273 Caption string `json:"caption"`
1274 ResponseText string `json:"responseText"`
1275}
1276
1277type MultipleChoiceParams struct {
1278 Question string `json:"question"`
1279 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1280}
1281
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001282// branchExists reports whether branchName exists, either locally or in well-known remotes.
1283func branchExists(dir, branchName string) bool {
1284 refs := []string{
1285 "refs/heads/",
1286 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001287 }
1288 for _, ref := range refs {
1289 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1290 cmd.Dir = dir
1291 if cmd.Run() == nil { // exit code 0 means branch exists
1292 return true
1293 }
1294 }
1295 return false
1296}
1297
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001298func (a *Agent) setSlugTool() *llm.Tool {
1299 return &llm.Tool{
1300 Name: "set-slug",
1301 Description: `Set a short slug as an identifier for this conversation.`,
Earl Lee2e463fb2025-04-17 11:22:22 -07001302 InputSchema: json.RawMessage(`{
1303 "type": "object",
1304 "properties": {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001305 "slug": {
Earl Lee2e463fb2025-04-17 11:22:22 -07001306 "type": "string",
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001307 "description": "A 2-3 word alphanumeric hyphenated slug, imperative tense"
Earl Lee2e463fb2025-04-17 11:22:22 -07001308 }
1309 },
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001310 "required": ["slug"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001311}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001312 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001313 var params struct {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001314 Slug string `json:"slug"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001315 }
1316 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001317 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001318 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001319 // Prevent slug changes if there have been git changes
1320 // This lets the agent change its mind about a good slug,
1321 // while ensuring that once a branch has been pushed, it remains stable.
1322 if s := a.Slug(); s != "" && s != params.Slug && a.gitState.HasSeenCommits() {
1323 return nil, fmt.Errorf("slug already set to %q", s)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001324 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001325 if params.Slug == "" {
1326 return nil, fmt.Errorf("slug parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001327 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001328 slug := cleanSlugName(params.Slug)
1329 if slug == "" {
1330 return nil, fmt.Errorf("slug parameter could not be converted to a valid slug")
1331 }
1332 a.SetSlug(slug)
1333 // TODO: do this by a call to outie, rather than semi-guessing from innie
1334 if branchExists(a.workingDir, a.BranchName()) {
1335 return nil, fmt.Errorf("slug %q already exists; please choose a different slug", slug)
1336 }
1337 return llm.TextContent("OK"), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001338 },
1339 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001340}
1341
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001342func (a *Agent) commitMessageStyleTool() *llm.Tool {
1343 description := `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 +00001344 preCommit := &llm.Tool{
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001345 Name: "commit-message-style",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001346 Description: description,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001347 InputSchema: llm.EmptySchema(),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001348 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001349 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1350 if err != nil {
1351 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1352 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001353 return llm.TextContent(styleHint), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001354 },
1355 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001356 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001357}
1358
1359func (a *Agent) Ready() <-chan struct{} {
1360 return a.ready
1361}
1362
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001363// BranchPrefix returns the configured branch prefix
1364func (a *Agent) BranchPrefix() string {
1365 return a.config.BranchPrefix
1366}
1367
Earl Lee2e463fb2025-04-17 11:22:22 -07001368func (a *Agent) UserMessage(ctx context.Context, msg string) {
1369 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1370 a.inbox <- msg
1371}
1372
Earl Lee2e463fb2025-04-17 11:22:22 -07001373func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1374 return a.convo.CancelToolUse(toolUseID, cause)
1375}
1376
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001377func (a *Agent) CancelTurn(cause error) {
1378 a.cancelTurnMu.Lock()
1379 defer a.cancelTurnMu.Unlock()
1380 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001381 // Force state transition to cancelled state
1382 ctx := a.config.Context
1383 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001384 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001385 }
1386}
1387
1388func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001389 // Start port monitoring when the agent loop begins
1390 // Only monitor ports when running in a container
1391 if a.IsInContainer() {
1392 a.portMonitor.Start(ctxOuter)
1393 }
1394
Earl Lee2e463fb2025-04-17 11:22:22 -07001395 for {
1396 select {
1397 case <-ctxOuter.Done():
1398 return
1399 default:
1400 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001401 a.cancelTurnMu.Lock()
1402 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001403 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001404 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001405 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001406 a.cancelTurn = cancel
1407 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001408 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1409 if err != nil {
1410 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1411 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001412 cancel(nil)
1413 }
1414 }
1415}
1416
1417func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1418 if m.Timestamp.IsZero() {
1419 m.Timestamp = time.Now()
1420 }
1421
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001422 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1423 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1424 m.Content = m.ToolResult
1425 }
1426
Earl Lee2e463fb2025-04-17 11:22:22 -07001427 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1428 if m.EndOfTurn && m.Type == AgentMessageType {
1429 turnDuration := time.Since(a.startOfTurn)
1430 m.TurnDuration = &turnDuration
1431 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1432 }
1433
Earl Lee2e463fb2025-04-17 11:22:22 -07001434 a.mu.Lock()
1435 defer a.mu.Unlock()
1436 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001437 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001438 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001439
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001440 // Notify all subscribers
1441 for _, ch := range a.subscribers {
1442 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001443 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001444}
1445
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001446func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1447 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001448 if block {
1449 select {
1450 case <-ctx.Done():
1451 return m, ctx.Err()
1452 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001453 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001454 }
1455 }
1456 for {
1457 select {
1458 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001459 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001460 default:
1461 return m, nil
1462 }
1463 }
1464}
1465
Sean McCullough885a16a2025-04-30 02:49:25 +00001466// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001467func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001468 // Reset the start of turn time
1469 a.startOfTurn = time.Now()
1470
Sean McCullough96b60dd2025-04-30 09:49:10 -07001471 // Transition to waiting for user input state
1472 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1473
Sean McCullough885a16a2025-04-30 02:49:25 +00001474 // Process initial user message
1475 initialResp, err := a.processUserMessage(ctx)
1476 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001477 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001478 return err
1479 }
1480
1481 // Handle edge case where both initialResp and err are nil
1482 if initialResp == nil {
1483 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001484 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1485
Sean McCullough9f4b8082025-04-30 17:34:07 +00001486 a.pushToOutbox(ctx, errorMessage(err))
1487 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001488 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001489
Earl Lee2e463fb2025-04-17 11:22:22 -07001490 // We do this as we go, but let's also do it at the end of the turn
1491 defer func() {
1492 if _, err := a.handleGitCommits(ctx); err != nil {
1493 // Just log the error, don't stop execution
1494 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1495 }
1496 }()
1497
Sean McCullougha1e0e492025-05-01 10:51:08 -07001498 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001499 resp := initialResp
1500 for {
1501 // Check if we are over budget
1502 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001503 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001504 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001505 }
1506
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001507 // Check if we should compact the conversation
1508 if a.ShouldCompact() {
1509 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1510 if err := a.CompactConversation(ctx); err != nil {
1511 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1512 return err
1513 }
1514 // After compaction, end this turn and start fresh
1515 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1516 return nil
1517 }
1518
Sean McCullough885a16a2025-04-30 02:49:25 +00001519 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001520 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001521 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001522 break
1523 }
1524
Sean McCullough96b60dd2025-04-30 09:49:10 -07001525 // Transition to tool use requested state
1526 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1527
Sean McCullough885a16a2025-04-30 02:49:25 +00001528 // Handle tool execution
1529 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1530 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001531 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001532 }
1533
Sean McCullougha1e0e492025-05-01 10:51:08 -07001534 if toolResp == nil {
1535 return fmt.Errorf("cannot continue conversation with a nil tool response")
1536 }
1537
Sean McCullough885a16a2025-04-30 02:49:25 +00001538 // Set the response for the next iteration
1539 resp = toolResp
1540 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001541
1542 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001543}
1544
1545// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001546func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001547 // Wait for at least one message from the user
1548 msgs, err := a.GatherMessages(ctx, true)
1549 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001550 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001551 return nil, err
1552 }
1553
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001554 userMessage := llm.Message{
1555 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001556 Content: msgs,
1557 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001558
Sean McCullough96b60dd2025-04-30 09:49:10 -07001559 // Transition to sending to LLM state
1560 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1561
Sean McCullough885a16a2025-04-30 02:49:25 +00001562 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001563 resp, err := a.convo.SendMessage(userMessage)
1564 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001565 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001566 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001567 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001568 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001569
Sean McCullough96b60dd2025-04-30 09:49:10 -07001570 // Transition to processing LLM response state
1571 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1572
Sean McCullough885a16a2025-04-30 02:49:25 +00001573 return resp, nil
1574}
1575
1576// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001577func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1578 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001579 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001580 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001581
Sean McCullough96b60dd2025-04-30 09:49:10 -07001582 // Transition to checking for cancellation state
1583 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1584
Sean McCullough885a16a2025-04-30 02:49:25 +00001585 // Check if the operation was cancelled by the user
1586 select {
1587 case <-ctx.Done():
1588 // Don't actually run any of the tools, but rather build a response
1589 // for each tool_use message letting the LLM know that user canceled it.
1590 var err error
1591 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001592 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001593 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001594 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001595 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001596 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001597 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001598 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001599 // Transition to running tool state
1600 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1601
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001602 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001603 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001604 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001605
1606 // Execute the tools
1607 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001608 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001609 if ctx.Err() != nil { // e.g. the user canceled the operation
1610 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001611 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001612 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001613 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001614 a.pushToOutbox(ctx, errorMessage(err))
1615 }
1616 }
1617
1618 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001619 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001620 autoqualityMessages := a.processGitChanges(ctx)
1621
1622 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001623 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001624 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001625 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001626 return false, nil
1627 }
1628
1629 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001630 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1631 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001632}
1633
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001634// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001635func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001636 // Check for git commits
1637 _, err := a.handleGitCommits(ctx)
1638 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001639 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001640 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001641 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001642 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001643}
1644
1645// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1646// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001647func (a *Agent) processGitChanges(ctx context.Context) []string {
1648 // Check for git commits after tool execution
1649 newCommits, err := a.handleGitCommits(ctx)
1650 if err != nil {
1651 // Just log the error, don't stop execution
1652 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1653 return nil
1654 }
1655
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001656 // Run mechanical checks if there was exactly one new commit.
1657 if len(newCommits) != 1 {
1658 return nil
1659 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001660 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001661 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1662 msg := a.codereview.RunMechanicalChecks(ctx)
1663 if msg != "" {
1664 a.pushToOutbox(ctx, AgentMessage{
1665 Type: AutoMessageType,
1666 Content: msg,
1667 Timestamp: time.Now(),
1668 })
1669 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001670 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001671
1672 return autoqualityMessages
1673}
1674
1675// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001676func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001677 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001678 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001679 msgs, err := a.GatherMessages(ctx, false)
1680 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001681 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001682 return false, nil
1683 }
1684
1685 // Inject any auto-generated messages from quality checks
1686 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001687 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001688 }
1689
1690 // Handle cancellation by appending a message about it
1691 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001692 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001693 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001694 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001695 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1696 } else if err := a.convo.OverBudget(); err != nil {
1697 // Handle budget issues by appending a message about it
1698 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 -07001699 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001700 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1701 }
1702
1703 // Combine tool results with user messages
1704 results = append(results, msgs...)
1705
1706 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001707 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001708 resp, err := a.convo.SendMessage(llm.Message{
1709 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001710 Content: results,
1711 })
1712 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001713 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001714 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1715 return true, nil // Return true to continue the conversation, but with no response
1716 }
1717
Sean McCullough96b60dd2025-04-30 09:49:10 -07001718 // Transition back to processing LLM response
1719 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1720
Sean McCullough885a16a2025-04-30 02:49:25 +00001721 if cancelled {
1722 return false, nil
1723 }
1724
1725 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001726}
1727
1728func (a *Agent) overBudget(ctx context.Context) error {
1729 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001730 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001731 m := budgetMessage(err)
1732 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001733 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001734 a.convo.ResetBudget(a.originalBudget)
1735 return err
1736 }
1737 return nil
1738}
1739
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001740func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001741 // Collect all text content
1742 var allText strings.Builder
1743 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001744 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001745 if allText.Len() > 0 {
1746 allText.WriteString("\n\n")
1747 }
1748 allText.WriteString(content.Text)
1749 }
1750 }
1751 return allText.String()
1752}
1753
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001754func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001755 a.mu.Lock()
1756 defer a.mu.Unlock()
1757 return a.convo.CumulativeUsage()
1758}
1759
Earl Lee2e463fb2025-04-17 11:22:22 -07001760// Diff returns a unified diff of changes made since the agent was instantiated.
1761func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001762 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001763 return "", fmt.Errorf("no initial commit reference available")
1764 }
1765
1766 // Find the repository root
1767 ctx := context.Background()
1768
1769 // If a specific commit hash is provided, show just that commit's changes
1770 if commit != nil && *commit != "" {
1771 // Validate that the commit looks like a valid git SHA
1772 if !isValidGitSHA(*commit) {
1773 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1774 }
1775
1776 // Get the diff for just this commit
1777 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1778 cmd.Dir = a.repoRoot
1779 output, err := cmd.CombinedOutput()
1780 if err != nil {
1781 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1782 }
1783 return string(output), nil
1784 }
1785
1786 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001787 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001788 cmd.Dir = a.repoRoot
1789 output, err := cmd.CombinedOutput()
1790 if err != nil {
1791 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1792 }
1793
1794 return string(output), nil
1795}
1796
Philip Zeyliger49edc922025-05-14 09:45:45 -07001797// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1798// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1799func (a *Agent) SketchGitBaseRef() string {
1800 if a.IsInContainer() {
1801 return "sketch-base"
1802 } else {
1803 return "sketch-base-" + a.SessionID()
1804 }
1805}
1806
1807// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1808func (a *Agent) SketchGitBase() string {
1809 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1810 cmd.Dir = a.repoRoot
1811 output, err := cmd.CombinedOutput()
1812 if err != nil {
1813 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1814 return "HEAD"
1815 }
1816 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001817}
1818
Pokey Rule7a113622025-05-12 10:58:45 +01001819// removeGitHooks removes the Git hooks directory from the repository
1820func removeGitHooks(_ context.Context, repoPath string) error {
1821 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1822
1823 // Check if hooks directory exists
1824 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1825 // Directory doesn't exist, nothing to do
1826 return nil
1827 }
1828
1829 // Remove the hooks directory
1830 err := os.RemoveAll(hooksDir)
1831 if err != nil {
1832 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1833 }
1834
1835 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001836 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001837 if err != nil {
1838 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1839 }
1840
1841 return nil
1842}
1843
Philip Zeyligerf2872992025-05-22 10:35:28 -07001844func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001845 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001846 for _, msg := range msgs {
1847 a.pushToOutbox(ctx, msg)
1848 }
1849 return commits, error
1850}
1851
Earl Lee2e463fb2025-04-17 11:22:22 -07001852// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001853// under docker, new HEADs are pushed to a branch according to the slug.
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001854func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001855 ags.mu.Lock()
1856 defer ags.mu.Unlock()
1857
1858 msgs := []AgentMessage{}
1859 if repoRoot == "" {
1860 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001861 }
1862
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001863 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07001864 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001865 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001866 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001867 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001868 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001869 }
1870 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001871 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07001872 }()
1873
1874 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1875 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1876 // to the last 100 commits.
1877 var commits []*GitCommit
1878
1879 // Get commits since the initial commit
1880 // Format: <hash>\0<subject>\0<body>\0
1881 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1882 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001883 cmd := exec.CommandContext(ctx, "git", "log", "-n", "100", "--pretty=format:%H%x00%s%x00%b%x00", "^"+baseRef, sketch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001884 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001885 output, err := cmd.Output()
1886 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001887 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001888 }
1889
1890 // Parse git log output and filter out already seen commits
1891 parsedCommits := parseGitLog(string(output))
1892
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001893 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001894
1895 // Filter out commits we've already seen
1896 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001897 if commit.Hash == sketch {
1898 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07001899 }
1900
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001901 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
1902 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07001903 continue
1904 }
1905
1906 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001907 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001908
1909 // Add to our list of new commits
1910 commits = append(commits, &commit)
1911 }
1912
Philip Zeyligerf2872992025-05-22 10:35:28 -07001913 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001914 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001915 // I think this can only happen if we have a bug or if there's a race.
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001916 sketchCommit = &GitCommit{}
1917 sketchCommit.Hash = sketch
1918 sketchCommit.Subject = "unknown"
1919 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07001920 }
1921
Earl Lee2e463fb2025-04-17 11:22:22 -07001922 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1923 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1924 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001925
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001926 // Try up to 10 times with incrementing retry numbers if the branch is checked out on the remote
Philip Zeyliger113e2052025-05-09 21:59:40 +00001927 var out []byte
1928 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001929 originalRetryNumber := ags.retryNumber
1930 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001931 for retries := range 10 {
1932 if retries > 0 {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001933 ags.IncrementRetryNumber()
Philip Zeyliger113e2052025-05-09 21:59:40 +00001934 }
1935
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001936 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001937 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001938 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001939 out, err = cmd.CombinedOutput()
1940
1941 if err == nil {
1942 // Success! Break out of the retry loop
1943 break
1944 }
1945
1946 // Check if this is the "refusing to update checked out branch" error
1947 if !strings.Contains(string(out), "refusing to update checked out branch") {
1948 // This is a different error, so don't retry
1949 break
1950 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00001951 }
1952
1953 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001954 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001955 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001956 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001957 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001958 if ags.retryNumber != originalRetryNumber {
1959 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001960 msgs = append(msgs, AgentMessage{
1961 Type: AutoMessageType,
1962 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001963 Content: fmt.Sprintf("Branch renamed from %s to %s because the original branch is currently checked out on the remote.", originalBranchName, finalBranch),
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001964 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00001965 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001966 }
1967 }
1968
1969 // If we found new commits, create a message
1970 if len(commits) > 0 {
1971 msg := AgentMessage{
1972 Type: CommitMessageType,
1973 Timestamp: time.Now(),
1974 Commits: commits,
1975 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001976 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001977 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07001978 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001979}
1980
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001981func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001982 return strings.Map(func(r rune) rune {
1983 // lowercase
1984 if r >= 'A' && r <= 'Z' {
1985 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07001986 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00001987 // replace spaces with dashes
1988 if r == ' ' {
1989 return '-'
1990 }
1991 // allow alphanumerics and dashes
1992 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
1993 return r
1994 }
1995 return -1
1996 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07001997}
1998
1999// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2000// and returns an array of GitCommit structs.
2001func parseGitLog(output string) []GitCommit {
2002 var commits []GitCommit
2003
2004 // No output means no commits
2005 if len(output) == 0 {
2006 return commits
2007 }
2008
2009 // Split by NULL byte
2010 parts := strings.Split(output, "\x00")
2011
2012 // Process in triplets (hash, subject, body)
2013 for i := 0; i < len(parts); i++ {
2014 // Skip empty parts
2015 if parts[i] == "" {
2016 continue
2017 }
2018
2019 // This should be a hash
2020 hash := strings.TrimSpace(parts[i])
2021
2022 // Make sure we have at least a subject part available
2023 if i+1 >= len(parts) {
2024 break // No more parts available
2025 }
2026
2027 // Get the subject
2028 subject := strings.TrimSpace(parts[i+1])
2029
2030 // Get the body if available
2031 body := ""
2032 if i+2 < len(parts) {
2033 body = strings.TrimSpace(parts[i+2])
2034 }
2035
2036 // Skip to the next triplet
2037 i += 2
2038
2039 commits = append(commits, GitCommit{
2040 Hash: hash,
2041 Subject: subject,
2042 Body: body,
2043 })
2044 }
2045
2046 return commits
2047}
2048
2049func repoRoot(ctx context.Context, dir string) (string, error) {
2050 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2051 stderr := new(strings.Builder)
2052 cmd.Stderr = stderr
2053 cmd.Dir = dir
2054 out, err := cmd.Output()
2055 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002056 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002057 }
2058 return strings.TrimSpace(string(out)), nil
2059}
2060
2061func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2062 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2063 stderr := new(strings.Builder)
2064 cmd.Stderr = stderr
2065 cmd.Dir = dir
2066 out, err := cmd.Output()
2067 if err != nil {
2068 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2069 }
2070 // TODO: validate that out is valid hex
2071 return strings.TrimSpace(string(out)), nil
2072}
2073
2074// isValidGitSHA validates if a string looks like a valid git SHA hash.
2075// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2076func isValidGitSHA(sha string) bool {
2077 // Git SHA must be a hexadecimal string with at least 4 characters
2078 if len(sha) < 4 || len(sha) > 40 {
2079 return false
2080 }
2081
2082 // Check if the string only contains hexadecimal characters
2083 for _, char := range sha {
2084 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2085 return false
2086 }
2087 }
2088
2089 return true
2090}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002091
2092// getGitOrigin returns the URL of the git remote 'origin' if it exists
2093func getGitOrigin(ctx context.Context, dir string) string {
2094 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2095 cmd.Dir = dir
2096 stderr := new(strings.Builder)
2097 cmd.Stderr = stderr
2098 out, err := cmd.Output()
2099 if err != nil {
2100 return ""
2101 }
2102 return strings.TrimSpace(string(out))
2103}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002104
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002105// systemPromptData contains the data used to render the system prompt template
2106type systemPromptData struct {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002107 ClientGOOS string
2108 ClientGOARCH string
2109 WorkingDir string
2110 RepoRoot string
2111 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002112 Codebase *onstart.Codebase
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002113}
2114
2115// renderSystemPrompt renders the system prompt template.
2116func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002117 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002118 ClientGOOS: a.config.ClientGOOS,
2119 ClientGOARCH: a.config.ClientGOARCH,
2120 WorkingDir: a.workingDir,
2121 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002122 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002123 Codebase: a.codebase,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002124 }
2125
2126 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2127 if err != nil {
2128 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2129 }
2130 buf := new(strings.Builder)
2131 err = tmpl.Execute(buf, data)
2132 if err != nil {
2133 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2134 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002135 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002136 return buf.String()
2137}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002138
2139// StateTransitionIterator provides an iterator over state transitions.
2140type StateTransitionIterator interface {
2141 // Next blocks until a new state transition is available or context is done.
2142 // Returns nil if the context is cancelled.
2143 Next() *StateTransition
2144 // Close removes the listener and cleans up resources.
2145 Close()
2146}
2147
2148// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2149type StateTransitionIteratorImpl struct {
2150 agent *Agent
2151 ctx context.Context
2152 ch chan StateTransition
2153 unsubscribe func()
2154}
2155
2156// Next blocks until a new state transition is available or the context is cancelled.
2157func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2158 select {
2159 case <-s.ctx.Done():
2160 return nil
2161 case transition, ok := <-s.ch:
2162 if !ok {
2163 return nil
2164 }
2165 transitionCopy := transition
2166 return &transitionCopy
2167 }
2168}
2169
2170// Close removes the listener and cleans up resources.
2171func (s *StateTransitionIteratorImpl) Close() {
2172 if s.unsubscribe != nil {
2173 s.unsubscribe()
2174 s.unsubscribe = nil
2175 }
2176}
2177
2178// NewStateTransitionIterator returns an iterator that receives state transitions.
2179func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2180 a.mu.Lock()
2181 defer a.mu.Unlock()
2182
2183 // Create channel to receive state transitions
2184 ch := make(chan StateTransition, 10)
2185
2186 // Add a listener to the state machine
2187 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2188
2189 return &StateTransitionIteratorImpl{
2190 agent: a,
2191 ctx: ctx,
2192 ch: ch,
2193 unsubscribe: unsubscribe,
2194 }
2195}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002196
2197// setupGitHooks creates or updates git hooks in the specified working directory.
2198func setupGitHooks(workingDir string) error {
2199 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2200
2201 _, err := os.Stat(hooksDir)
2202 if os.IsNotExist(err) {
2203 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2204 }
2205 if err != nil {
2206 return fmt.Errorf("error checking git hooks directory: %w", err)
2207 }
2208
2209 // Define the post-commit hook content
2210 postCommitHook := `#!/bin/bash
2211echo "<post_commit_hook>"
2212echo "Please review this commit message and fix it if it is incorrect."
2213echo "This hook only echos the commit message; it does not modify it."
2214echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2215echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002216PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002217echo "</last_commit_message>"
2218echo "</post_commit_hook>"
2219`
2220
2221 // Define the prepare-commit-msg hook content
2222 prepareCommitMsgHook := `#!/bin/bash
2223# Add Co-Authored-By and Change-ID trailers to commit messages
2224# Check if these trailers already exist before adding them
2225
2226commit_file="$1"
2227COMMIT_SOURCE="$2"
2228
2229# Skip for merges, squashes, or when using a commit template
2230if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2231 [ "$COMMIT_SOURCE" = "squash" ]; then
2232 exit 0
2233fi
2234
2235commit_msg=$(cat "$commit_file")
2236
2237needs_co_author=true
2238needs_change_id=true
2239
2240# Check if commit message already has Co-Authored-By trailer
2241if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2242 needs_co_author=false
2243fi
2244
2245# Check if commit message already has Change-ID trailer
2246if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2247 needs_change_id=false
2248fi
2249
2250# Only modify if at least one trailer needs to be added
2251if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002252 # Ensure there's a proper blank line before trailers
2253 if [ -s "$commit_file" ]; then
2254 # Check if file ends with newline by reading last character
2255 last_char=$(tail -c 1 "$commit_file")
2256
2257 if [ "$last_char" != "" ]; then
2258 # File doesn't end with newline - add two newlines (complete line + blank line)
2259 echo "" >> "$commit_file"
2260 echo "" >> "$commit_file"
2261 else
2262 # File ends with newline - check if we already have a blank line
2263 last_line=$(tail -1 "$commit_file")
2264 if [ -n "$last_line" ]; then
2265 # Last line has content - add one newline for blank line
2266 echo "" >> "$commit_file"
2267 fi
2268 # If last line is empty, we already have a blank line - don't add anything
2269 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002270 fi
2271
2272 # Add trailers if needed
2273 if [ "$needs_co_author" = true ]; then
2274 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2275 fi
2276
2277 if [ "$needs_change_id" = true ]; then
2278 change_id=$(openssl rand -hex 8)
2279 echo "Change-ID: s${change_id}k" >> "$commit_file"
2280 fi
2281fi
2282`
2283
2284 // Update or create the post-commit hook
2285 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2286 if err != nil {
2287 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2288 }
2289
2290 // Update or create the prepare-commit-msg hook
2291 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2292 if err != nil {
2293 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2294 }
2295
2296 return nil
2297}
2298
2299// updateOrCreateHook creates a new hook file or updates an existing one
2300// by appending the new content if it doesn't already contain it.
2301func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2302 // Check if the hook already exists
2303 buf, err := os.ReadFile(hookPath)
2304 if os.IsNotExist(err) {
2305 // Hook doesn't exist, create it
2306 err = os.WriteFile(hookPath, []byte(content), 0o755)
2307 if err != nil {
2308 return fmt.Errorf("failed to create hook: %w", err)
2309 }
2310 return nil
2311 }
2312 if err != nil {
2313 return fmt.Errorf("error reading existing hook: %w", err)
2314 }
2315
2316 // Hook exists, check if our content is already in it by looking for a distinctive line
2317 code := string(buf)
2318 if strings.Contains(code, distinctiveLine) {
2319 // Already contains our content, nothing to do
2320 return nil
2321 }
2322
2323 // Append our content to the existing hook
2324 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2325 if err != nil {
2326 return fmt.Errorf("failed to open hook for appending: %w", err)
2327 }
2328 defer f.Close()
2329
2330 // Ensure there's a newline at the end of the existing content if needed
2331 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2332 _, err = f.WriteString("\n")
2333 if err != nil {
2334 return fmt.Errorf("failed to add newline to hook: %w", err)
2335 }
2336 }
2337
2338 // Add a separator before our content
2339 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2340 if err != nil {
2341 return fmt.Errorf("failed to append to hook: %w", err)
2342 }
2343
2344 return nil
2345}
Sean McCullough138ec242025-06-02 22:42:06 +00002346
2347// GetPortMonitor returns the port monitor instance for accessing port events
2348func (a *Agent) GetPortMonitor() *PortMonitor {
2349 return a.portMonitor
2350}