blob: 25c3807b427d1a0bab62b3205dcc554edbb50300 [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
philip.zeyliger6d3de482025-06-10 19:38:14 -070071 // LinkToGitHub returns whether GitHub branch linking is enabled
72 LinkToGitHub() bool
73
Sean McCulloughedc88dc2025-04-30 02:55:01 +000074 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070075
76 CancelToolUse(toolUseID string, cause error) error
77
78 // Returns a subset of the agent's message history.
79 Messages(start int, end int) []AgentMessage
80
81 // Returns the current number of messages in the history
82 MessageCount() int
83
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070084 TotalUsage() conversation.CumulativeUsage
85 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070086
Earl Lee2e463fb2025-04-17 11:22:22 -070087 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000088 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070089
90 // Diff returns a unified diff of changes made since the agent was instantiated.
91 // If commit is non-nil, it shows the diff for just that specific commit.
92 Diff(commit *string) (string, error)
93
Philip Zeyliger49edc922025-05-14 09:45:45 -070094 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
95 // starts out as the commit where sketch started, but a user can move it if need
96 // be, for example in the case of a rebase. It is stored as a git tag.
97 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070098
Philip Zeyligerd3ac1122025-05-14 02:54:18 +000099 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
100 // (Typically, this is "sketch-base")
101 SketchGitBaseRef() string
102
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700103 // Slug returns the slug identifier for this session.
104 Slug() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700105
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000106 // BranchName returns the git branch name for the conversation.
107 BranchName() string
108
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700109 // IncrementRetryNumber increments the retry number for branch naming conflicts.
110 IncrementRetryNumber()
111
Earl Lee2e463fb2025-04-17 11:22:22 -0700112 // OS returns the operating system of the client.
113 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000114
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000115 // SessionID returns the unique session identifier.
116 SessionID() string
117
philip.zeyliger8773e682025-06-11 21:36:21 -0700118 // SSHConnectionString returns the SSH connection string for the container.
119 SSHConnectionString() string
120
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000121 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700122 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000123
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000124 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
125 OutstandingLLMCallCount() int
126
127 // OutstandingToolCalls returns the names of outstanding tool calls.
128 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000129 OutsideOS() string
130 OutsideHostname() string
131 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000132 GitOrigin() string
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000133 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
134 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700135
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700136 // IsInContainer returns true if the agent is running in a container
137 IsInContainer() bool
138 // FirstMessageIndex returns the index of the first message in the current conversation
139 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700140
141 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700142 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
143 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700144
145 // CompactConversation compacts the current conversation by generating a summary
146 // and restarting the conversation with that summary as the initial context
147 CompactConversation(ctx context.Context) error
Sean McCullough138ec242025-06-02 22:42:06 +0000148 // GetPortMonitor returns the port monitor instance for accessing port events
149 GetPortMonitor() *PortMonitor
Philip Zeyliger0113be52025-06-07 23:53:41 +0000150 // SkabandAddr returns the skaband address if configured
151 SkabandAddr() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700152}
153
154type CodingAgentMessageType string
155
156const (
157 UserMessageType CodingAgentMessageType = "user"
158 AgentMessageType CodingAgentMessageType = "agent"
159 ErrorMessageType CodingAgentMessageType = "error"
160 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
161 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700162 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
163 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
164 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700165
166 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
167)
168
169type AgentMessage struct {
170 Type CodingAgentMessageType `json:"type"`
171 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
172 EndOfTurn bool `json:"end_of_turn"`
173
174 Content string `json:"content"`
175 ToolName string `json:"tool_name,omitempty"`
176 ToolInput string `json:"input,omitempty"`
177 ToolResult string `json:"tool_result,omitempty"`
178 ToolError bool `json:"tool_error,omitempty"`
179 ToolCallId string `json:"tool_call_id,omitempty"`
180
181 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
182 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
183
Sean McCulloughd9f13372025-04-21 15:08:49 -0700184 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
185 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
186
Earl Lee2e463fb2025-04-17 11:22:22 -0700187 // Commits is a list of git commits for a commit message
188 Commits []*GitCommit `json:"commits,omitempty"`
189
190 Timestamp time.Time `json:"timestamp"`
191 ConversationID string `json:"conversation_id"`
192 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700193 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700194
195 // Message timing information
196 StartTime *time.Time `json:"start_time,omitempty"`
197 EndTime *time.Time `json:"end_time,omitempty"`
198 Elapsed *time.Duration `json:"elapsed,omitempty"`
199
200 // Turn duration - the time taken for a complete agent turn
201 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
202
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000203 // HideOutput indicates that this message should not be rendered in the UI.
204 // This is useful for subconversations that generate output that shouldn't be shown to the user.
205 HideOutput bool `json:"hide_output,omitempty"`
206
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700207 // TodoContent contains the agent's todo file content when it has changed
208 TodoContent *string `json:"todo_content,omitempty"`
209
Earl Lee2e463fb2025-04-17 11:22:22 -0700210 Idx int `json:"idx"`
211}
212
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000213// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700214func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700215 if convo == nil {
216 m.ConversationID = ""
217 m.ParentConversationID = nil
218 return
219 }
220 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000221 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700222 if convo.Parent != nil {
223 m.ParentConversationID = &convo.Parent.ID
224 }
225}
226
Earl Lee2e463fb2025-04-17 11:22:22 -0700227// GitCommit represents a single git commit for a commit message
228type GitCommit struct {
229 Hash string `json:"hash"` // Full commit hash
230 Subject string `json:"subject"` // Commit subject line
231 Body string `json:"body"` // Full commit message body
232 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
233}
234
235// ToolCall represents a single tool call within an agent message
236type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700237 Name string `json:"name"`
238 Input string `json:"input"`
239 ToolCallId string `json:"tool_call_id"`
240 ResultMessage *AgentMessage `json:"result_message,omitempty"`
241 Args string `json:"args,omitempty"`
242 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700243}
244
245func (a *AgentMessage) Attr() slog.Attr {
246 var attrs []any = []any{
247 slog.String("type", string(a.Type)),
248 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700249 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700250 if a.EndOfTurn {
251 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
252 }
253 if a.Content != "" {
254 attrs = append(attrs, slog.String("content", a.Content))
255 }
256 if a.ToolName != "" {
257 attrs = append(attrs, slog.String("tool_name", a.ToolName))
258 }
259 if a.ToolInput != "" {
260 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
261 }
262 if a.Elapsed != nil {
263 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
264 }
265 if a.TurnDuration != nil {
266 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
267 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700268 if len(a.ToolResult) > 0 {
269 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700270 }
271 if a.ToolError {
272 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
273 }
274 if len(a.ToolCalls) > 0 {
275 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
276 for i, tc := range a.ToolCalls {
277 toolCallAttrs = append(toolCallAttrs, slog.Group(
278 fmt.Sprintf("tool_call_%d", i),
279 slog.String("name", tc.Name),
280 slog.String("input", tc.Input),
281 ))
282 }
283 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
284 }
285 if a.ConversationID != "" {
286 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
287 }
288 if a.ParentConversationID != nil {
289 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
290 }
291 if a.Usage != nil && !a.Usage.IsZero() {
292 attrs = append(attrs, a.Usage.Attr())
293 }
294 // TODO: timestamp, convo ids, idx?
295 return slog.Group("agent_message", attrs...)
296}
297
298func errorMessage(err error) AgentMessage {
299 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
300 if os.Getenv(("DEBUG")) == "1" {
301 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
302 }
303
304 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
305}
306
307func budgetMessage(err error) AgentMessage {
308 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
309}
310
311// ConvoInterface defines the interface for conversation interactions
312type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700313 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700314 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700315 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700316 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700317 SendMessage(message llm.Message) (*llm.Response, error)
318 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700319 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000320 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700321 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700322 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700323 SubConvoWithHistory() *conversation.Convo
Earl Lee2e463fb2025-04-17 11:22:22 -0700324}
325
Philip Zeyligerf2872992025-05-22 10:35:28 -0700326// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700327// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700328// any time we notice we need to.
329type AgentGitState struct {
330 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700331 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700332 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000333 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700334 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700335 slug string // Human-readable session identifier
336 retryNumber int // Number to append when branch conflicts occur
Philip Zeyligerf2872992025-05-22 10:35:28 -0700337}
338
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700339func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700340 ags.mu.Lock()
341 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700342 if ags.slug != slug {
343 ags.retryNumber = 0
344 }
345 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700346}
347
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700348func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700349 ags.mu.Lock()
350 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700351 return ags.slug
352}
353
354func (ags *AgentGitState) IncrementRetryNumber() {
355 ags.mu.Lock()
356 defer ags.mu.Unlock()
357 ags.retryNumber++
358}
359
360// HasSeenCommits returns true if any commits have been processed
361func (ags *AgentGitState) HasSeenCommits() bool {
362 ags.mu.Lock()
363 defer ags.mu.Unlock()
364 return len(ags.seenCommits) > 0
365}
366
367func (ags *AgentGitState) RetryNumber() int {
368 ags.mu.Lock()
369 defer ags.mu.Unlock()
370 return ags.retryNumber
371}
372
373func (ags *AgentGitState) BranchName(prefix string) string {
374 ags.mu.Lock()
375 defer ags.mu.Unlock()
376 return ags.branchNameLocked(prefix)
377}
378
379func (ags *AgentGitState) branchNameLocked(prefix string) string {
380 if ags.slug == "" {
381 return ""
382 }
383 if ags.retryNumber == 0 {
384 return prefix + ags.slug
385 }
386 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700387}
388
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000389func (ags *AgentGitState) Upstream() string {
390 ags.mu.Lock()
391 defer ags.mu.Unlock()
392 return ags.upstream
393}
394
Earl Lee2e463fb2025-04-17 11:22:22 -0700395type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700396 convo ConvoInterface
397 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700398 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700399 workingDir string
400 repoRoot string // workingDir may be a subdir of repoRoot
401 url string
402 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000403 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700404 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000405 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700406 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700407 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000408 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700409 // State machine to track agent state
410 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000411 // Outside information
412 outsideHostname string
413 outsideOS string
414 outsideWorkingDir string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000415 // URL of the git remote 'origin' if it exists
416 gitOrigin string
Earl Lee2e463fb2025-04-17 11:22:22 -0700417
418 // Time when the current turn started (reset at the beginning of InnerLoop)
419 startOfTurn time.Time
420
421 // Inbox - for messages from the user to the agent.
422 // sent on by UserMessage
423 // . e.g. when user types into the chat textarea
424 // read from by GatherMessages
425 inbox chan string
426
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000427 // protects cancelTurn
428 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700429 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000430 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700431
432 // protects following
433 mu sync.Mutex
434
435 // Stores all messages for this agent
436 history []AgentMessage
437
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700438 // Iterators add themselves here when they're ready to be notified of new messages.
439 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700440
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000441 // Track outstanding LLM call IDs
442 outstandingLLMCalls map[string]struct{}
443
444 // Track outstanding tool calls by ID with their names
445 outstandingToolCalls map[string]string
Sean McCullough364f7412025-06-02 00:55:44 +0000446
447 // Port monitoring
448 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700449}
450
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700451// NewIterator implements CodingAgent.
452func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
453 a.mu.Lock()
454 defer a.mu.Unlock()
455
456 return &MessageIteratorImpl{
457 agent: a,
458 ctx: ctx,
459 nextMessageIdx: nextMessageIdx,
460 ch: make(chan *AgentMessage, 100),
461 }
462}
463
464type MessageIteratorImpl struct {
465 agent *Agent
466 ctx context.Context
467 nextMessageIdx int
468 ch chan *AgentMessage
469 subscribed bool
470}
471
472func (m *MessageIteratorImpl) Close() {
473 m.agent.mu.Lock()
474 defer m.agent.mu.Unlock()
475 // Delete ourselves from the subscribers list
476 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
477 return x == m.ch
478 })
479 close(m.ch)
480}
481
482func (m *MessageIteratorImpl) Next() *AgentMessage {
483 // We avoid subscription at creation to let ourselves catch up to "current state"
484 // before subscribing.
485 if !m.subscribed {
486 m.agent.mu.Lock()
487 if m.nextMessageIdx < len(m.agent.history) {
488 msg := &m.agent.history[m.nextMessageIdx]
489 m.nextMessageIdx++
490 m.agent.mu.Unlock()
491 return msg
492 }
493 // The next message doesn't exist yet, so let's subscribe
494 m.agent.subscribers = append(m.agent.subscribers, m.ch)
495 m.subscribed = true
496 m.agent.mu.Unlock()
497 }
498
499 for {
500 select {
501 case <-m.ctx.Done():
502 m.agent.mu.Lock()
503 // Delete ourselves from the subscribers list
504 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
505 return x == m.ch
506 })
507 m.subscribed = false
508 m.agent.mu.Unlock()
509 return nil
510 case msg, ok := <-m.ch:
511 if !ok {
512 // Close may have been called
513 return nil
514 }
515 if msg.Idx == m.nextMessageIdx {
516 m.nextMessageIdx++
517 return msg
518 }
519 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
520 panic("out of order message")
521 }
522 }
523}
524
Sean McCulloughd9d45812025-04-30 16:53:41 -0700525// Assert that Agent satisfies the CodingAgent interface.
526var _ CodingAgent = &Agent{}
527
528// StateName implements CodingAgent.
529func (a *Agent) CurrentStateName() string {
530 if a.stateMachine == nil {
531 return ""
532 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000533 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700534}
535
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700536// CurrentTodoContent returns the current todo list data as JSON.
537// It returns an empty string if no todos exist.
538func (a *Agent) CurrentTodoContent() string {
539 todoPath := claudetool.TodoFilePath(a.config.SessionID)
540 content, err := os.ReadFile(todoPath)
541 if err != nil {
542 return ""
543 }
544 return string(content)
545}
546
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700547// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
548func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
549 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.
550
551IMPORTANT: 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.
552
553Please create a detailed summary that includes:
554
5551. **User's Request**: What did the user originally ask me to do? What was their goal?
556
5572. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
558
5593. **Key Technical Decisions**: What important technical choices were made during our work and why?
560
5614. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
562
5635. **Next Steps**: What still needs to be done to complete the user's request?
564
5656. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
566
567Focus 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.
568
569Reply with ONLY the summary content - no meta-commentary about creating the summary.`
570
571 userMessage := llm.UserStringMessage(msg)
572 // Use a subconversation with history to get the summary
573 // TODO: We don't have any tools here, so we should have enough tokens
574 // to capture a summary, but we may need to modify the history (e.g., remove
575 // TODO data) to save on some tokens.
576 convo := a.convo.SubConvoWithHistory()
577
578 // Modify the system prompt to provide context about the original task
579 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000580 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 -0700581
582Your 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.
583
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000584Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700585
586 resp, err := convo.SendMessage(userMessage)
587 if err != nil {
588 a.pushToOutbox(ctx, errorMessage(err))
589 return "", err
590 }
591 textContent := collectTextContent(resp)
592
593 // Restore original system prompt (though this subconvo will be discarded)
594 convo.SystemPrompt = originalSystemPrompt
595
596 return textContent, nil
597}
598
599// CompactConversation compacts the current conversation by generating a summary
600// and restarting the conversation with that summary as the initial context
601func (a *Agent) CompactConversation(ctx context.Context) error {
602 summary, err := a.generateConversationSummary(ctx)
603 if err != nil {
604 return fmt.Errorf("failed to generate conversation summary: %w", err)
605 }
606
607 a.mu.Lock()
608
609 // Get usage information before resetting conversation
610 lastUsage := a.convo.LastUsage()
611 contextWindow := a.config.Service.TokenContextWindow()
612 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
613
614 // Reset conversation state but keep all other state (git, working dir, etc.)
615 a.firstMessageIndex = len(a.history)
616 a.convo = a.initConvo()
617
618 a.mu.Unlock()
619
620 // Create informative compaction message with token details
621 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
622 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
623 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
624
625 a.pushToOutbox(ctx, AgentMessage{
626 Type: CompactMessageType,
627 Content: compactionMsg,
628 })
629
630 a.pushToOutbox(ctx, AgentMessage{
631 Type: UserMessageType,
632 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),
633 })
634 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)
635
636 return nil
637}
638
Earl Lee2e463fb2025-04-17 11:22:22 -0700639func (a *Agent) URL() string { return a.url }
640
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000641// BranchName returns the git branch name for the conversation.
642func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700643 return a.gitState.BranchName(a.config.BranchPrefix)
644}
645
646// Slug returns the slug identifier for this conversation.
647func (a *Agent) Slug() string {
648 return a.gitState.Slug()
649}
650
651// IncrementRetryNumber increments the retry number for branch naming conflicts
652func (a *Agent) IncrementRetryNumber() {
653 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000654}
655
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000656// OutstandingLLMCallCount returns the number of outstanding LLM calls.
657func (a *Agent) OutstandingLLMCallCount() int {
658 a.mu.Lock()
659 defer a.mu.Unlock()
660 return len(a.outstandingLLMCalls)
661}
662
663// OutstandingToolCalls returns the names of outstanding tool calls.
664func (a *Agent) OutstandingToolCalls() []string {
665 a.mu.Lock()
666 defer a.mu.Unlock()
667
668 tools := make([]string, 0, len(a.outstandingToolCalls))
669 for _, toolName := range a.outstandingToolCalls {
670 tools = append(tools, toolName)
671 }
672 return tools
673}
674
Earl Lee2e463fb2025-04-17 11:22:22 -0700675// OS returns the operating system of the client.
676func (a *Agent) OS() string {
677 return a.config.ClientGOOS
678}
679
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000680func (a *Agent) SessionID() string {
681 return a.config.SessionID
682}
683
philip.zeyliger8773e682025-06-11 21:36:21 -0700684// SSHConnectionString returns the SSH connection string for the container.
685func (a *Agent) SSHConnectionString() string {
686 return a.config.SSHConnectionString
687}
688
Philip Zeyliger18532b22025-04-23 21:11:46 +0000689// OutsideOS returns the operating system of the outside system.
690func (a *Agent) OutsideOS() string {
691 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000692}
693
Philip Zeyliger18532b22025-04-23 21:11:46 +0000694// OutsideHostname returns the hostname of the outside system.
695func (a *Agent) OutsideHostname() string {
696 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000697}
698
Philip Zeyliger18532b22025-04-23 21:11:46 +0000699// OutsideWorkingDir returns the working directory on the outside system.
700func (a *Agent) OutsideWorkingDir() string {
701 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000702}
703
704// GitOrigin returns the URL of the git remote 'origin' if it exists.
705func (a *Agent) GitOrigin() string {
706 return a.gitOrigin
707}
708
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000709func (a *Agent) OpenBrowser(url string) {
710 if !a.IsInContainer() {
711 browser.Open(url)
712 return
713 }
714 // We're in Docker, need to send a request to the Git server
715 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700716 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000717 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700718 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000719 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700720 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000721 return
722 }
723 defer resp.Body.Close()
724 if resp.StatusCode == http.StatusOK {
725 return
726 }
727 body, _ := io.ReadAll(resp.Body)
728 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
729}
730
Sean McCullough96b60dd2025-04-30 09:49:10 -0700731// CurrentState returns the current state of the agent's state machine.
732func (a *Agent) CurrentState() State {
733 return a.stateMachine.CurrentState()
734}
735
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700736func (a *Agent) IsInContainer() bool {
737 return a.config.InDocker
738}
739
740func (a *Agent) FirstMessageIndex() int {
741 a.mu.Lock()
742 defer a.mu.Unlock()
743 return a.firstMessageIndex
744}
745
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700746// SetSlug sets a human-readable identifier for the conversation.
747func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700748 a.mu.Lock()
749 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700750
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700751 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000752 convo, ok := a.convo.(*conversation.Convo)
753 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700754 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000755 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700756}
757
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000758// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700759func (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 +0000760 // Track the tool call
761 a.mu.Lock()
762 a.outstandingToolCalls[id] = toolName
763 a.mu.Unlock()
764}
765
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700766// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
767// If there's only one element in the array and it's a text type, it returns that text directly.
768// It also processes nested ToolResult arrays recursively.
769func contentToString(contents []llm.Content) string {
770 if len(contents) == 0 {
771 return ""
772 }
773
774 // If there's only one element and it's a text type, return it directly
775 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
776 return contents[0].Text
777 }
778
779 // Otherwise, concatenate all text content
780 var result strings.Builder
781 for _, content := range contents {
782 if content.Type == llm.ContentTypeText {
783 result.WriteString(content.Text)
784 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
785 // Recursively process nested tool results
786 result.WriteString(contentToString(content.ToolResult))
787 }
788 }
789
790 return result.String()
791}
792
Earl Lee2e463fb2025-04-17 11:22:22 -0700793// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700794func (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 +0000795 // Remove the tool call from outstanding calls
796 a.mu.Lock()
797 delete(a.outstandingToolCalls, toolID)
798 a.mu.Unlock()
799
Earl Lee2e463fb2025-04-17 11:22:22 -0700800 m := AgentMessage{
801 Type: ToolUseMessageType,
802 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700803 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700804 ToolError: content.ToolError,
805 ToolName: toolName,
806 ToolInput: string(toolInput),
807 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700808 StartTime: content.ToolUseStartTime,
809 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700810 }
811
812 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700813 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
814 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700815 m.Elapsed = &elapsed
816 }
817
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700818 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700819 a.pushToOutbox(ctx, m)
820}
821
822// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700823func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000824 a.mu.Lock()
825 defer a.mu.Unlock()
826 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700827 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
828}
829
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700830// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700831// that need to be displayed (as well as tool calls that we send along when
832// they're done). (It would be reasonable to also mention tool calls when they're
833// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700834func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000835 // Remove the LLM call from outstanding calls
836 a.mu.Lock()
837 delete(a.outstandingLLMCalls, id)
838 a.mu.Unlock()
839
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700840 if resp == nil {
841 // LLM API call failed
842 m := AgentMessage{
843 Type: ErrorMessageType,
844 Content: "API call failed, type 'continue' to try again",
845 }
846 m.SetConvo(convo)
847 a.pushToOutbox(ctx, m)
848 return
849 }
850
Earl Lee2e463fb2025-04-17 11:22:22 -0700851 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700852 if convo.Parent == nil { // subconvos never end the turn
853 switch resp.StopReason {
854 case llm.StopReasonToolUse:
855 // Check whether any of the tool calls are for tools that should end the turn
856 ToolSearch:
857 for _, part := range resp.Content {
858 if part.Type != llm.ContentTypeToolUse {
859 continue
860 }
Sean McCullough021557a2025-05-05 23:20:53 +0000861 // Find the tool by name
862 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700863 if tool.Name == part.ToolName {
864 endOfTurn = tool.EndsTurn
865 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000866 }
867 }
Sean McCullough021557a2025-05-05 23:20:53 +0000868 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700869 default:
870 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000871 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700872 }
873 m := AgentMessage{
874 Type: AgentMessageType,
875 Content: collectTextContent(resp),
876 EndOfTurn: endOfTurn,
877 Usage: &resp.Usage,
878 StartTime: resp.StartTime,
879 EndTime: resp.EndTime,
880 }
881
882 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700883 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700884 var toolCalls []ToolCall
885 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700886 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700887 toolCalls = append(toolCalls, ToolCall{
888 Name: part.ToolName,
889 Input: string(part.ToolInput),
890 ToolCallId: part.ID,
891 })
892 }
893 }
894 m.ToolCalls = toolCalls
895 }
896
897 // Calculate the elapsed time if both start and end times are set
898 if resp.StartTime != nil && resp.EndTime != nil {
899 elapsed := resp.EndTime.Sub(*resp.StartTime)
900 m.Elapsed = &elapsed
901 }
902
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700903 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700904 a.pushToOutbox(ctx, m)
905}
906
907// WorkingDir implements CodingAgent.
908func (a *Agent) WorkingDir() string {
909 return a.workingDir
910}
911
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +0000912// RepoRoot returns the git repository root directory.
913func (a *Agent) RepoRoot() string {
914 return a.repoRoot
915}
916
Earl Lee2e463fb2025-04-17 11:22:22 -0700917// MessageCount implements CodingAgent.
918func (a *Agent) MessageCount() int {
919 a.mu.Lock()
920 defer a.mu.Unlock()
921 return len(a.history)
922}
923
924// Messages implements CodingAgent.
925func (a *Agent) Messages(start int, end int) []AgentMessage {
926 a.mu.Lock()
927 defer a.mu.Unlock()
928 return slices.Clone(a.history[start:end])
929}
930
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700931// ShouldCompact checks if the conversation should be compacted based on token usage
932func (a *Agent) ShouldCompact() bool {
933 // Get the threshold from environment variable, default to 0.94 (94%)
934 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
935 // and a little bit of buffer.)
936 thresholdRatio := 0.94
937 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
938 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
939 thresholdRatio = parsed
940 }
941 }
942
943 // Get the most recent usage to check current context size
944 lastUsage := a.convo.LastUsage()
945
946 if lastUsage.InputTokens == 0 {
947 // No API calls made yet
948 return false
949 }
950
951 // Calculate the current context size from the last API call
952 // This includes all tokens that were part of the input context:
953 // - Input tokens (user messages, system prompt, conversation history)
954 // - Cache read tokens (cached parts of the context)
955 // - Cache creation tokens (new parts being cached)
956 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
957
958 // Get the service's token context window
959 service := a.config.Service
960 contextWindow := service.TokenContextWindow()
961
962 // Calculate threshold
963 threshold := uint64(float64(contextWindow) * thresholdRatio)
964
965 // Check if we've exceeded the threshold
966 return currentContextSize >= threshold
967}
968
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700969func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -0700970 return a.originalBudget
971}
972
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000973// Upstream returns the upstream branch for git work
974func (a *Agent) Upstream() string {
975 return a.gitState.Upstream()
976}
977
Earl Lee2e463fb2025-04-17 11:22:22 -0700978// AgentConfig contains configuration for creating a new Agent.
979type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +0000980 Context context.Context
981 Service llm.Service
982 Budget conversation.Budget
983 GitUsername string
984 GitEmail string
985 SessionID string
986 ClientGOOS string
987 ClientGOARCH string
988 InDocker bool
989 OneShot bool
990 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000991 // Outside information
992 OutsideHostname string
993 OutsideOS string
994 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -0700995
996 // Outtie's HTTP to, e.g., open a browser
997 OutsideHTTP string
998 // Outtie's Git server
999 GitRemoteAddr string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001000 // Upstream branch for git work
1001 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001002 // Commit to checkout from Outtie
1003 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001004 // Prefix for git branches created by sketch
1005 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001006 // LinkToGitHub enables GitHub branch linking in UI
1007 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001008 // SSH connection string for connecting to the container
1009 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001010 // Skaband client for session history (optional)
1011 SkabandClient *skabandclient.SkabandClient
Earl Lee2e463fb2025-04-17 11:22:22 -07001012}
1013
1014// NewAgent creates a new Agent.
1015// It is not usable until Init() is called.
1016func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001017 // Set default branch prefix if not specified
1018 if config.BranchPrefix == "" {
1019 config.BranchPrefix = "sketch/"
1020 }
1021
Earl Lee2e463fb2025-04-17 11:22:22 -07001022 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001023 config: config,
1024 ready: make(chan struct{}),
1025 inbox: make(chan string, 100),
1026 subscribers: make([]chan *AgentMessage, 0),
1027 startedAt: time.Now(),
1028 originalBudget: config.Budget,
1029 gitState: AgentGitState{
1030 seenCommits: make(map[string]bool),
1031 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001032 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001033 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001034 outsideHostname: config.OutsideHostname,
1035 outsideOS: config.OutsideOS,
1036 outsideWorkingDir: config.OutsideWorkingDir,
1037 outstandingLLMCalls: make(map[string]struct{}),
1038 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001039 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001040 workingDir: config.WorkingDir,
1041 outsideHTTP: config.OutsideHTTP,
Sean McCullough364f7412025-06-02 00:55:44 +00001042 portMonitor: NewPortMonitor(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001043 }
1044 return agent
1045}
1046
1047type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001048 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001049
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001050 InDocker bool
1051 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001052}
1053
1054func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001055 if a.convo != nil {
1056 return fmt.Errorf("Agent.Init: already initialized")
1057 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001058 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001059 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001060
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001061 if !ini.NoGit {
1062 // Capture the original origin before we potentially replace it below
1063 a.gitOrigin = getGitOrigin(ctx, a.workingDir)
1064 }
1065
Philip Zeyliger222bf412025-06-04 16:42:58 +00001066 // If a remote git addr was specified, we configure the origin remote
Philip Zeyligerf2872992025-05-22 10:35:28 -07001067 if a.gitState.gitRemoteAddr != "" {
1068 slog.InfoContext(ctx, "Configuring git remote", slog.String("remote", a.gitState.gitRemoteAddr))
Philip Zeyliger222bf412025-06-04 16:42:58 +00001069
1070 // Remove existing origin remote if it exists
1071 cmd := exec.CommandContext(ctx, "git", "remote", "remove", "origin")
Philip Zeyligerf2872992025-05-22 10:35:28 -07001072 cmd.Dir = a.workingDir
1073 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001074 // Ignore error if origin doesn't exist
1075 slog.DebugContext(ctx, "git remote remove origin (ignoring if not exists)", slog.String("output", string(out)))
Philip Zeyligerf2872992025-05-22 10:35:28 -07001076 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001077
1078 // Add the new remote as origin
1079 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", a.gitState.gitRemoteAddr)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001080 cmd.Dir = a.workingDir
1081 if out, err := cmd.CombinedOutput(); err != nil {
Philip Zeyliger222bf412025-06-04 16:42:58 +00001082 return fmt.Errorf("git remote add origin: %s: %v", out, err)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001083 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001084
Philip Zeyligerf2872992025-05-22 10:35:28 -07001085 }
1086
1087 // If a commit was specified, we fetch and reset to it.
1088 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001089 slog.InfoContext(ctx, "updating git repo", slog.String("commit", a.config.Commit))
1090
Earl Lee2e463fb2025-04-17 11:22:22 -07001091 cmd := exec.CommandContext(ctx, "git", "stash")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001092 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001093 if out, err := cmd.CombinedOutput(); err != nil {
1094 return fmt.Errorf("git stash: %s: %v", out, err)
1095 }
Philip Zeyliger222bf412025-06-04 16:42:58 +00001096 cmd = exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001097 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001098 if out, err := cmd.CombinedOutput(); err != nil {
1099 return fmt.Errorf("git fetch: %s: %w", out, err)
1100 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001101 // The -B resets the branch if it already exists (or creates it if it doesn't)
1102 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001103 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001104 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1105 // Remove git hooks if they exist and retry
1106 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001107 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001108 if _, statErr := os.Stat(hookPath); statErr == nil {
1109 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1110 slog.String("error", err.Error()),
1111 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001112 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001113 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1114 }
1115
1116 // Retry the checkout operation
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001117 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", a.config.Commit)
1118 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001119 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001120 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 +01001121 }
1122 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001123 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001124 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001125 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001126 } else if a.IsInContainer() {
1127 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1128 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1129 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1130 cmd.Dir = a.workingDir
1131 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1132 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1133 }
1134 } else {
1135 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001136 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001137
1138 if ini.HostAddr != "" {
1139 a.url = "http://" + ini.HostAddr
1140 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001141
1142 if !ini.NoGit {
1143 repoRoot, err := repoRoot(ctx, a.workingDir)
1144 if err != nil {
1145 return fmt.Errorf("repoRoot: %w", err)
1146 }
1147 a.repoRoot = repoRoot
1148
Earl Lee2e463fb2025-04-17 11:22:22 -07001149 if err != nil {
1150 return fmt.Errorf("resolveRef: %w", err)
1151 }
Philip Zeyliger49edc922025-05-14 09:45:45 -07001152
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001153 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001154 if err := setupGitHooks(a.repoRoot); err != nil {
1155 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1156 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001157 }
1158
Philip Zeyliger49edc922025-05-14 09:45:45 -07001159 cmd := exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
1160 cmd.Dir = repoRoot
1161 if out, err := cmd.CombinedOutput(); err != nil {
1162 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1163 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001164
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001165 slog.Info("running codebase analysis")
1166 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1167 if err != nil {
1168 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001169 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001170 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001171
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001172 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001173 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001174 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001175 }
1176 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001177
Earl Lee2e463fb2025-04-17 11:22:22 -07001178 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001179 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001180 a.convo = a.initConvo()
1181 close(a.ready)
1182 return nil
1183}
1184
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001185//go:embed agent_system_prompt.txt
1186var agentSystemPrompt string
1187
Earl Lee2e463fb2025-04-17 11:22:22 -07001188// initConvo initializes the conversation.
1189// It must not be called until all agent fields are initialized,
1190// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001191func (a *Agent) initConvo() *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001192 ctx := a.config.Context
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001193 convo := conversation.New(ctx, a.config.Service)
Earl Lee2e463fb2025-04-17 11:22:22 -07001194 convo.PromptCaching = true
1195 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001196 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001197 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001198
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001199 // Define a permission callback for the bash tool to check if the branch name is set before allowing git commits
1200 bashPermissionCheck := func(command string) error {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001201 if a.gitState.Slug() != "" {
1202 return nil // branch is set up
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001203 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001204 willCommit, err := bashkit.WillRunGitCommit(command)
1205 if err != nil {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001206 return nil // fail open
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001207 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001208 if willCommit {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001209 return fmt.Errorf("you must use the set-slug tool before making git commits")
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001210 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001211 return nil
1212 }
1213
Josh Bleecher Snyder495c1fa2025-05-29 00:37:22 +00001214 bashTool := claudetool.NewBashTool(bashPermissionCheck, claudetool.EnableBashToolJITInstall)
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001215
Earl Lee2e463fb2025-04-17 11:22:22 -07001216 // Register all tools with the conversation
1217 // When adding, removing, or modifying tools here, double-check that the termui tool display
1218 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001219
1220 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001221 _, supportsScreenshots := a.config.Service.(*ant.Service)
1222 var bTools []*llm.Tool
1223 var browserCleanup func()
1224
1225 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1226 // Add cleanup function to context cancel
1227 go func() {
1228 <-a.config.Context.Done()
1229 browserCleanup()
1230 }()
1231 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001232
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001233 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001234 bashTool, claudetool.Keyword, claudetool.Patch,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001235 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.setSlugTool(), a.commitMessageStyleTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001236 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001237 }
1238
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001239 // One-shot mode is non-interactive, multiple choice requires human response
1240 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001241 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001242 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001243
1244 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001245
1246 // Add session history tools if skaband client is available
1247 if a.config.SkabandClient != nil {
1248 sessionHistoryTools := claudetool.CreateSessionHistoryTools(a.config.SkabandClient, a.config.SessionID, a.gitOrigin)
1249 convo.Tools = append(convo.Tools, sessionHistoryTools...)
1250 }
1251
Earl Lee2e463fb2025-04-17 11:22:22 -07001252 convo.Listener = a
1253 return convo
1254}
1255
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001256var multipleChoiceTool = &llm.Tool{
1257 Name: "multiplechoice",
1258 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.",
1259 EndsTurn: true,
1260 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001261 "type": "object",
1262 "description": "The question and a list of answers you would expect the user to choose from.",
1263 "properties": {
1264 "question": {
1265 "type": "string",
1266 "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?'"
1267 },
1268 "responseOptions": {
1269 "type": "array",
1270 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1271 "items": {
1272 "type": "object",
1273 "properties": {
1274 "caption": {
1275 "type": "string",
1276 "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'"
1277 },
1278 "responseText": {
1279 "type": "string",
1280 "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'"
1281 }
1282 },
1283 "required": ["caption", "responseText"]
1284 }
1285 }
1286 },
1287 "required": ["question", "responseOptions"]
1288}`),
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001289 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
1290 // The Run logic for "multiplechoice" tool is a no-op on the server.
1291 // The UI will present a list of options for the user to select from,
1292 // and that's it as far as "executing" the tool_use goes.
1293 // When the user *does* select one of the presented options, that
1294 // responseText gets sent as a chat message on behalf of the user.
1295 return llm.TextContent("end your turn and wait for the user to respond"), nil
1296 },
Sean McCullough485afc62025-04-28 14:28:39 -07001297}
1298
1299type MultipleChoiceOption struct {
1300 Caption string `json:"caption"`
1301 ResponseText string `json:"responseText"`
1302}
1303
1304type MultipleChoiceParams struct {
1305 Question string `json:"question"`
1306 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1307}
1308
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001309// branchExists reports whether branchName exists, either locally or in well-known remotes.
1310func branchExists(dir, branchName string) bool {
1311 refs := []string{
1312 "refs/heads/",
1313 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001314 }
1315 for _, ref := range refs {
1316 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1317 cmd.Dir = dir
1318 if cmd.Run() == nil { // exit code 0 means branch exists
1319 return true
1320 }
1321 }
1322 return false
1323}
1324
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001325func (a *Agent) setSlugTool() *llm.Tool {
1326 return &llm.Tool{
1327 Name: "set-slug",
1328 Description: `Set a short slug as an identifier for this conversation.`,
Earl Lee2e463fb2025-04-17 11:22:22 -07001329 InputSchema: json.RawMessage(`{
1330 "type": "object",
1331 "properties": {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001332 "slug": {
Earl Lee2e463fb2025-04-17 11:22:22 -07001333 "type": "string",
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001334 "description": "A 2-3 word alphanumeric hyphenated slug, imperative tense"
Earl Lee2e463fb2025-04-17 11:22:22 -07001335 }
1336 },
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001337 "required": ["slug"]
Earl Lee2e463fb2025-04-17 11:22:22 -07001338}`),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001339 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Earl Lee2e463fb2025-04-17 11:22:22 -07001340 var params struct {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001341 Slug string `json:"slug"`
Earl Lee2e463fb2025-04-17 11:22:22 -07001342 }
1343 if err := json.Unmarshal(input, &params); err != nil {
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001344 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001345 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001346 // Prevent slug changes if there have been git changes
1347 // This lets the agent change its mind about a good slug,
1348 // while ensuring that once a branch has been pushed, it remains stable.
1349 if s := a.Slug(); s != "" && s != params.Slug && a.gitState.HasSeenCommits() {
1350 return nil, fmt.Errorf("slug already set to %q", s)
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001351 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001352 if params.Slug == "" {
1353 return nil, fmt.Errorf("slug parameter cannot be empty")
Josh Bleecher Snydera9b38222025-04-29 18:05:06 -07001354 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001355 slug := cleanSlugName(params.Slug)
1356 if slug == "" {
1357 return nil, fmt.Errorf("slug parameter could not be converted to a valid slug")
1358 }
1359 a.SetSlug(slug)
1360 // TODO: do this by a call to outie, rather than semi-guessing from innie
1361 if branchExists(a.workingDir, a.BranchName()) {
1362 return nil, fmt.Errorf("slug %q already exists; please choose a different slug", slug)
1363 }
1364 return llm.TextContent("OK"), nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001365 },
1366 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001367}
1368
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001369func (a *Agent) commitMessageStyleTool() *llm.Tool {
1370 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 +00001371 preCommit := &llm.Tool{
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001372 Name: "commit-message-style",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001373 Description: description,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001374 InputSchema: llm.EmptySchema(),
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001375 Run: func(ctx context.Context, input json.RawMessage) ([]llm.Content, error) {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001376 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1377 if err != nil {
1378 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1379 }
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001380 return llm.TextContent(styleHint), nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001381 },
1382 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001383 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001384}
1385
1386func (a *Agent) Ready() <-chan struct{} {
1387 return a.ready
1388}
1389
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001390// BranchPrefix returns the configured branch prefix
1391func (a *Agent) BranchPrefix() string {
1392 return a.config.BranchPrefix
1393}
1394
philip.zeyliger6d3de482025-06-10 19:38:14 -07001395// LinkToGitHub returns whether GitHub branch linking is enabled
1396func (a *Agent) LinkToGitHub() bool {
1397 return a.config.LinkToGitHub
1398}
1399
Earl Lee2e463fb2025-04-17 11:22:22 -07001400func (a *Agent) UserMessage(ctx context.Context, msg string) {
1401 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1402 a.inbox <- msg
1403}
1404
Earl Lee2e463fb2025-04-17 11:22:22 -07001405func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1406 return a.convo.CancelToolUse(toolUseID, cause)
1407}
1408
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001409func (a *Agent) CancelTurn(cause error) {
1410 a.cancelTurnMu.Lock()
1411 defer a.cancelTurnMu.Unlock()
1412 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001413 // Force state transition to cancelled state
1414 ctx := a.config.Context
1415 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001416 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001417 }
1418}
1419
1420func (a *Agent) Loop(ctxOuter context.Context) {
Sean McCullough364f7412025-06-02 00:55:44 +00001421 // Start port monitoring when the agent loop begins
1422 // Only monitor ports when running in a container
1423 if a.IsInContainer() {
1424 a.portMonitor.Start(ctxOuter)
1425 }
1426
Earl Lee2e463fb2025-04-17 11:22:22 -07001427 for {
1428 select {
1429 case <-ctxOuter.Done():
1430 return
1431 default:
1432 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001433 a.cancelTurnMu.Lock()
1434 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001435 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001436 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001437 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001438 a.cancelTurn = cancel
1439 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001440 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1441 if err != nil {
1442 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1443 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001444 cancel(nil)
1445 }
1446 }
1447}
1448
1449func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1450 if m.Timestamp.IsZero() {
1451 m.Timestamp = time.Now()
1452 }
1453
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001454 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1455 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1456 m.Content = m.ToolResult
1457 }
1458
Earl Lee2e463fb2025-04-17 11:22:22 -07001459 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1460 if m.EndOfTurn && m.Type == AgentMessageType {
1461 turnDuration := time.Since(a.startOfTurn)
1462 m.TurnDuration = &turnDuration
1463 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1464 }
1465
Earl Lee2e463fb2025-04-17 11:22:22 -07001466 a.mu.Lock()
1467 defer a.mu.Unlock()
1468 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001469 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001470 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001471
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001472 // Notify all subscribers
1473 for _, ch := range a.subscribers {
1474 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001475 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001476}
1477
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001478func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1479 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001480 if block {
1481 select {
1482 case <-ctx.Done():
1483 return m, ctx.Err()
1484 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001485 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001486 }
1487 }
1488 for {
1489 select {
1490 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001491 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001492 default:
1493 return m, nil
1494 }
1495 }
1496}
1497
Sean McCullough885a16a2025-04-30 02:49:25 +00001498// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001499func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001500 // Reset the start of turn time
1501 a.startOfTurn = time.Now()
1502
Sean McCullough96b60dd2025-04-30 09:49:10 -07001503 // Transition to waiting for user input state
1504 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1505
Sean McCullough885a16a2025-04-30 02:49:25 +00001506 // Process initial user message
1507 initialResp, err := a.processUserMessage(ctx)
1508 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001509 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001510 return err
1511 }
1512
1513 // Handle edge case where both initialResp and err are nil
1514 if initialResp == nil {
1515 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001516 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1517
Sean McCullough9f4b8082025-04-30 17:34:07 +00001518 a.pushToOutbox(ctx, errorMessage(err))
1519 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001520 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001521
Earl Lee2e463fb2025-04-17 11:22:22 -07001522 // We do this as we go, but let's also do it at the end of the turn
1523 defer func() {
1524 if _, err := a.handleGitCommits(ctx); err != nil {
1525 // Just log the error, don't stop execution
1526 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1527 }
1528 }()
1529
Sean McCullougha1e0e492025-05-01 10:51:08 -07001530 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001531 resp := initialResp
1532 for {
1533 // Check if we are over budget
1534 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001535 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001536 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001537 }
1538
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001539 // Check if we should compact the conversation
1540 if a.ShouldCompact() {
1541 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1542 if err := a.CompactConversation(ctx); err != nil {
1543 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1544 return err
1545 }
1546 // After compaction, end this turn and start fresh
1547 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1548 return nil
1549 }
1550
Sean McCullough885a16a2025-04-30 02:49:25 +00001551 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001552 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001553 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001554 break
1555 }
1556
Sean McCullough96b60dd2025-04-30 09:49:10 -07001557 // Transition to tool use requested state
1558 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1559
Sean McCullough885a16a2025-04-30 02:49:25 +00001560 // Handle tool execution
1561 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1562 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001563 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001564 }
1565
Sean McCullougha1e0e492025-05-01 10:51:08 -07001566 if toolResp == nil {
1567 return fmt.Errorf("cannot continue conversation with a nil tool response")
1568 }
1569
Sean McCullough885a16a2025-04-30 02:49:25 +00001570 // Set the response for the next iteration
1571 resp = toolResp
1572 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001573
1574 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001575}
1576
1577// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001578func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001579 // Wait for at least one message from the user
1580 msgs, err := a.GatherMessages(ctx, true)
1581 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001582 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001583 return nil, err
1584 }
1585
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001586 userMessage := llm.Message{
1587 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001588 Content: msgs,
1589 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001590
Sean McCullough96b60dd2025-04-30 09:49:10 -07001591 // Transition to sending to LLM state
1592 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1593
Sean McCullough885a16a2025-04-30 02:49:25 +00001594 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001595 resp, err := a.convo.SendMessage(userMessage)
1596 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001597 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001598 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001599 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001600 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001601
Sean McCullough96b60dd2025-04-30 09:49:10 -07001602 // Transition to processing LLM response state
1603 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1604
Sean McCullough885a16a2025-04-30 02:49:25 +00001605 return resp, nil
1606}
1607
1608// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001609func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1610 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001611 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001612 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001613
Sean McCullough96b60dd2025-04-30 09:49:10 -07001614 // Transition to checking for cancellation state
1615 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1616
Sean McCullough885a16a2025-04-30 02:49:25 +00001617 // Check if the operation was cancelled by the user
1618 select {
1619 case <-ctx.Done():
1620 // Don't actually run any of the tools, but rather build a response
1621 // for each tool_use message letting the LLM know that user canceled it.
1622 var err error
1623 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001624 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001625 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001626 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001627 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001628 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001629 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001630 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001631 // Transition to running tool state
1632 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1633
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001634 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001635 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001636 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001637
1638 // Execute the tools
1639 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001640 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001641 if ctx.Err() != nil { // e.g. the user canceled the operation
1642 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001643 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001644 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001645 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001646 a.pushToOutbox(ctx, errorMessage(err))
1647 }
1648 }
1649
1650 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001651 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001652 autoqualityMessages := a.processGitChanges(ctx)
1653
1654 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001655 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001656 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001657 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001658 return false, nil
1659 }
1660
1661 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001662 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1663 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001664}
1665
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001666// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001667func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001668 // Check for git commits
1669 _, err := a.handleGitCommits(ctx)
1670 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001671 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001672 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001673 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001674 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001675}
1676
1677// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1678// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001679func (a *Agent) processGitChanges(ctx context.Context) []string {
1680 // Check for git commits after tool execution
1681 newCommits, err := a.handleGitCommits(ctx)
1682 if err != nil {
1683 // Just log the error, don't stop execution
1684 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1685 return nil
1686 }
1687
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001688 // Run mechanical checks if there was exactly one new commit.
1689 if len(newCommits) != 1 {
1690 return nil
1691 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001692 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001693 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1694 msg := a.codereview.RunMechanicalChecks(ctx)
1695 if msg != "" {
1696 a.pushToOutbox(ctx, AgentMessage{
1697 Type: AutoMessageType,
1698 Content: msg,
1699 Timestamp: time.Now(),
1700 })
1701 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001702 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001703
1704 return autoqualityMessages
1705}
1706
1707// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001708func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001709 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001710 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001711 msgs, err := a.GatherMessages(ctx, false)
1712 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001713 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001714 return false, nil
1715 }
1716
1717 // Inject any auto-generated messages from quality checks
1718 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001719 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001720 }
1721
1722 // Handle cancellation by appending a message about it
1723 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001724 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001725 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001726 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001727 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1728 } else if err := a.convo.OverBudget(); err != nil {
1729 // Handle budget issues by appending a message about it
1730 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 -07001731 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001732 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1733 }
1734
1735 // Combine tool results with user messages
1736 results = append(results, msgs...)
1737
1738 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001739 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001740 resp, err := a.convo.SendMessage(llm.Message{
1741 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001742 Content: results,
1743 })
1744 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001745 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001746 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1747 return true, nil // Return true to continue the conversation, but with no response
1748 }
1749
Sean McCullough96b60dd2025-04-30 09:49:10 -07001750 // Transition back to processing LLM response
1751 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1752
Sean McCullough885a16a2025-04-30 02:49:25 +00001753 if cancelled {
1754 return false, nil
1755 }
1756
1757 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001758}
1759
1760func (a *Agent) overBudget(ctx context.Context) error {
1761 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001762 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001763 m := budgetMessage(err)
1764 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001765 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001766 a.convo.ResetBudget(a.originalBudget)
1767 return err
1768 }
1769 return nil
1770}
1771
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001772func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001773 // Collect all text content
1774 var allText strings.Builder
1775 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001776 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001777 if allText.Len() > 0 {
1778 allText.WriteString("\n\n")
1779 }
1780 allText.WriteString(content.Text)
1781 }
1782 }
1783 return allText.String()
1784}
1785
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001786func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001787 a.mu.Lock()
1788 defer a.mu.Unlock()
1789 return a.convo.CumulativeUsage()
1790}
1791
Earl Lee2e463fb2025-04-17 11:22:22 -07001792// Diff returns a unified diff of changes made since the agent was instantiated.
1793func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001794 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001795 return "", fmt.Errorf("no initial commit reference available")
1796 }
1797
1798 // Find the repository root
1799 ctx := context.Background()
1800
1801 // If a specific commit hash is provided, show just that commit's changes
1802 if commit != nil && *commit != "" {
1803 // Validate that the commit looks like a valid git SHA
1804 if !isValidGitSHA(*commit) {
1805 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
1806 }
1807
1808 // Get the diff for just this commit
1809 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
1810 cmd.Dir = a.repoRoot
1811 output, err := cmd.CombinedOutput()
1812 if err != nil {
1813 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
1814 }
1815 return string(output), nil
1816 }
1817
1818 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07001819 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001820 cmd.Dir = a.repoRoot
1821 output, err := cmd.CombinedOutput()
1822 if err != nil {
1823 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
1824 }
1825
1826 return string(output), nil
1827}
1828
Philip Zeyliger49edc922025-05-14 09:45:45 -07001829// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
1830// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
1831func (a *Agent) SketchGitBaseRef() string {
1832 if a.IsInContainer() {
1833 return "sketch-base"
1834 } else {
1835 return "sketch-base-" + a.SessionID()
1836 }
1837}
1838
1839// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
1840func (a *Agent) SketchGitBase() string {
1841 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
1842 cmd.Dir = a.repoRoot
1843 output, err := cmd.CombinedOutput()
1844 if err != nil {
1845 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
1846 return "HEAD"
1847 }
1848 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001849}
1850
Pokey Rule7a113622025-05-12 10:58:45 +01001851// removeGitHooks removes the Git hooks directory from the repository
1852func removeGitHooks(_ context.Context, repoPath string) error {
1853 hooksDir := filepath.Join(repoPath, ".git", "hooks")
1854
1855 // Check if hooks directory exists
1856 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
1857 // Directory doesn't exist, nothing to do
1858 return nil
1859 }
1860
1861 // Remove the hooks directory
1862 err := os.RemoveAll(hooksDir)
1863 if err != nil {
1864 return fmt.Errorf("failed to remove git hooks directory: %w", err)
1865 }
1866
1867 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00001868 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01001869 if err != nil {
1870 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
1871 }
1872
1873 return nil
1874}
1875
Philip Zeyligerf2872992025-05-22 10:35:28 -07001876func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001877 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.SessionID(), a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001878 for _, msg := range msgs {
1879 a.pushToOutbox(ctx, msg)
1880 }
1881 return commits, error
1882}
1883
Earl Lee2e463fb2025-04-17 11:22:22 -07001884// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001885// under docker, new HEADs are pushed to a branch according to the slug.
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001886func (ags *AgentGitState) handleGitCommits(ctx context.Context, sessionID string, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001887 ags.mu.Lock()
1888 defer ags.mu.Unlock()
1889
1890 msgs := []AgentMessage{}
1891 if repoRoot == "" {
1892 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07001893 }
1894
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001895 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07001896 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001897 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001898 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001899 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001900 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07001901 }
1902 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001903 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07001904 }()
1905
1906 // Get new commits. Because it's possible that the agent does rebases, fixups, and
1907 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
1908 // to the last 100 commits.
1909 var commits []*GitCommit
1910
1911 // Get commits since the initial commit
1912 // Format: <hash>\0<subject>\0<body>\0
1913 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
1914 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001915 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 -07001916 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07001917 output, err := cmd.Output()
1918 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001919 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001920 }
1921
1922 // Parse git log output and filter out already seen commits
1923 parsedCommits := parseGitLog(string(output))
1924
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001925 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001926
1927 // Filter out commits we've already seen
1928 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001929 if commit.Hash == sketch {
1930 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07001931 }
1932
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001933 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
1934 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07001935 continue
1936 }
1937
1938 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07001939 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07001940
1941 // Add to our list of new commits
1942 commits = append(commits, &commit)
1943 }
1944
Philip Zeyligerf2872992025-05-22 10:35:28 -07001945 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001946 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07001947 // 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 -07001948 sketchCommit = &GitCommit{}
1949 sketchCommit.Hash = sketch
1950 sketchCommit.Subject = "unknown"
1951 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07001952 }
1953
Earl Lee2e463fb2025-04-17 11:22:22 -07001954 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
1955 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
1956 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00001957
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001958 // 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 +00001959 var out []byte
1960 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001961 originalRetryNumber := ags.retryNumber
1962 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00001963 for retries := range 10 {
1964 if retries > 0 {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001965 ags.IncrementRetryNumber()
Philip Zeyliger113e2052025-05-09 21:59:40 +00001966 }
1967
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001968 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001969 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07001970 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00001971 out, err = cmd.CombinedOutput()
1972
1973 if err == nil {
1974 // Success! Break out of the retry loop
1975 break
1976 }
1977
1978 // Check if this is the "refusing to update checked out branch" error
1979 if !strings.Contains(string(out), "refusing to update checked out branch") {
1980 // This is a different error, so don't retry
1981 break
1982 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00001983 }
1984
1985 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07001986 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07001987 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001988 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001989 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001990 if ags.retryNumber != originalRetryNumber {
1991 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00001992 msgs = append(msgs, AgentMessage{
1993 Type: AutoMessageType,
1994 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001995 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 +00001996 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00001997 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001998 }
1999 }
2000
2001 // If we found new commits, create a message
2002 if len(commits) > 0 {
2003 msg := AgentMessage{
2004 Type: CommitMessageType,
2005 Timestamp: time.Now(),
2006 Commits: commits,
2007 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002008 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002009 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002010 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002011}
2012
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002013func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002014 return strings.Map(func(r rune) rune {
2015 // lowercase
2016 if r >= 'A' && r <= 'Z' {
2017 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002018 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002019 // replace spaces with dashes
2020 if r == ' ' {
2021 return '-'
2022 }
2023 // allow alphanumerics and dashes
2024 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2025 return r
2026 }
2027 return -1
2028 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002029}
2030
2031// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2032// and returns an array of GitCommit structs.
2033func parseGitLog(output string) []GitCommit {
2034 var commits []GitCommit
2035
2036 // No output means no commits
2037 if len(output) == 0 {
2038 return commits
2039 }
2040
2041 // Split by NULL byte
2042 parts := strings.Split(output, "\x00")
2043
2044 // Process in triplets (hash, subject, body)
2045 for i := 0; i < len(parts); i++ {
2046 // Skip empty parts
2047 if parts[i] == "" {
2048 continue
2049 }
2050
2051 // This should be a hash
2052 hash := strings.TrimSpace(parts[i])
2053
2054 // Make sure we have at least a subject part available
2055 if i+1 >= len(parts) {
2056 break // No more parts available
2057 }
2058
2059 // Get the subject
2060 subject := strings.TrimSpace(parts[i+1])
2061
2062 // Get the body if available
2063 body := ""
2064 if i+2 < len(parts) {
2065 body = strings.TrimSpace(parts[i+2])
2066 }
2067
2068 // Skip to the next triplet
2069 i += 2
2070
2071 commits = append(commits, GitCommit{
2072 Hash: hash,
2073 Subject: subject,
2074 Body: body,
2075 })
2076 }
2077
2078 return commits
2079}
2080
2081func repoRoot(ctx context.Context, dir string) (string, error) {
2082 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2083 stderr := new(strings.Builder)
2084 cmd.Stderr = stderr
2085 cmd.Dir = dir
2086 out, err := cmd.Output()
2087 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002088 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002089 }
2090 return strings.TrimSpace(string(out)), nil
2091}
2092
2093func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2094 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2095 stderr := new(strings.Builder)
2096 cmd.Stderr = stderr
2097 cmd.Dir = dir
2098 out, err := cmd.Output()
2099 if err != nil {
2100 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2101 }
2102 // TODO: validate that out is valid hex
2103 return strings.TrimSpace(string(out)), nil
2104}
2105
2106// isValidGitSHA validates if a string looks like a valid git SHA hash.
2107// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2108func isValidGitSHA(sha string) bool {
2109 // Git SHA must be a hexadecimal string with at least 4 characters
2110 if len(sha) < 4 || len(sha) > 40 {
2111 return false
2112 }
2113
2114 // Check if the string only contains hexadecimal characters
2115 for _, char := range sha {
2116 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2117 return false
2118 }
2119 }
2120
2121 return true
2122}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002123
2124// getGitOrigin returns the URL of the git remote 'origin' if it exists
2125func getGitOrigin(ctx context.Context, dir string) string {
2126 cmd := exec.CommandContext(ctx, "git", "config", "--get", "remote.origin.url")
2127 cmd.Dir = dir
2128 stderr := new(strings.Builder)
2129 cmd.Stderr = stderr
2130 out, err := cmd.Output()
2131 if err != nil {
2132 return ""
2133 }
2134 return strings.TrimSpace(string(out))
2135}
Philip Zeyliger2c4db092025-04-28 16:57:50 -07002136
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002137// systemPromptData contains the data used to render the system prompt template
2138type systemPromptData struct {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002139 ClientGOOS string
2140 ClientGOARCH string
2141 WorkingDir string
2142 RepoRoot string
2143 InitialCommit string
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002144 Codebase *onstart.Codebase
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07002145 UseSketchWIP bool
2146 Branch string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002147}
2148
2149// renderSystemPrompt renders the system prompt template.
2150func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002151 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002152 ClientGOOS: a.config.ClientGOOS,
2153 ClientGOARCH: a.config.ClientGOARCH,
2154 WorkingDir: a.workingDir,
2155 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002156 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002157 Codebase: a.codebase,
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07002158 UseSketchWIP: a.config.InDocker,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002159 }
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002160 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2161 if err != nil {
2162 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2163 }
2164 buf := new(strings.Builder)
2165 err = tmpl.Execute(buf, data)
2166 if err != nil {
2167 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2168 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002169 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002170 return buf.String()
2171}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002172
2173// StateTransitionIterator provides an iterator over state transitions.
2174type StateTransitionIterator interface {
2175 // Next blocks until a new state transition is available or context is done.
2176 // Returns nil if the context is cancelled.
2177 Next() *StateTransition
2178 // Close removes the listener and cleans up resources.
2179 Close()
2180}
2181
2182// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2183type StateTransitionIteratorImpl struct {
2184 agent *Agent
2185 ctx context.Context
2186 ch chan StateTransition
2187 unsubscribe func()
2188}
2189
2190// Next blocks until a new state transition is available or the context is cancelled.
2191func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2192 select {
2193 case <-s.ctx.Done():
2194 return nil
2195 case transition, ok := <-s.ch:
2196 if !ok {
2197 return nil
2198 }
2199 transitionCopy := transition
2200 return &transitionCopy
2201 }
2202}
2203
2204// Close removes the listener and cleans up resources.
2205func (s *StateTransitionIteratorImpl) Close() {
2206 if s.unsubscribe != nil {
2207 s.unsubscribe()
2208 s.unsubscribe = nil
2209 }
2210}
2211
2212// NewStateTransitionIterator returns an iterator that receives state transitions.
2213func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2214 a.mu.Lock()
2215 defer a.mu.Unlock()
2216
2217 // Create channel to receive state transitions
2218 ch := make(chan StateTransition, 10)
2219
2220 // Add a listener to the state machine
2221 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2222
2223 return &StateTransitionIteratorImpl{
2224 agent: a,
2225 ctx: ctx,
2226 ch: ch,
2227 unsubscribe: unsubscribe,
2228 }
2229}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002230
2231// setupGitHooks creates or updates git hooks in the specified working directory.
2232func setupGitHooks(workingDir string) error {
2233 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2234
2235 _, err := os.Stat(hooksDir)
2236 if os.IsNotExist(err) {
2237 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2238 }
2239 if err != nil {
2240 return fmt.Errorf("error checking git hooks directory: %w", err)
2241 }
2242
2243 // Define the post-commit hook content
2244 postCommitHook := `#!/bin/bash
2245echo "<post_commit_hook>"
2246echo "Please review this commit message and fix it if it is incorrect."
2247echo "This hook only echos the commit message; it does not modify it."
2248echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2249echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002250PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002251echo "</last_commit_message>"
2252echo "</post_commit_hook>"
2253`
2254
2255 // Define the prepare-commit-msg hook content
2256 prepareCommitMsgHook := `#!/bin/bash
2257# Add Co-Authored-By and Change-ID trailers to commit messages
2258# Check if these trailers already exist before adding them
2259
2260commit_file="$1"
2261COMMIT_SOURCE="$2"
2262
2263# Skip for merges, squashes, or when using a commit template
2264if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2265 [ "$COMMIT_SOURCE" = "squash" ]; then
2266 exit 0
2267fi
2268
2269commit_msg=$(cat "$commit_file")
2270
2271needs_co_author=true
2272needs_change_id=true
2273
2274# Check if commit message already has Co-Authored-By trailer
2275if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2276 needs_co_author=false
2277fi
2278
2279# Check if commit message already has Change-ID trailer
2280if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2281 needs_change_id=false
2282fi
2283
2284# Only modify if at least one trailer needs to be added
2285if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002286 # Ensure there's a proper blank line before trailers
2287 if [ -s "$commit_file" ]; then
2288 # Check if file ends with newline by reading last character
2289 last_char=$(tail -c 1 "$commit_file")
2290
2291 if [ "$last_char" != "" ]; then
2292 # File doesn't end with newline - add two newlines (complete line + blank line)
2293 echo "" >> "$commit_file"
2294 echo "" >> "$commit_file"
2295 else
2296 # File ends with newline - check if we already have a blank line
2297 last_line=$(tail -1 "$commit_file")
2298 if [ -n "$last_line" ]; then
2299 # Last line has content - add one newline for blank line
2300 echo "" >> "$commit_file"
2301 fi
2302 # If last line is empty, we already have a blank line - don't add anything
2303 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002304 fi
2305
2306 # Add trailers if needed
2307 if [ "$needs_co_author" = true ]; then
2308 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2309 fi
2310
2311 if [ "$needs_change_id" = true ]; then
2312 change_id=$(openssl rand -hex 8)
2313 echo "Change-ID: s${change_id}k" >> "$commit_file"
2314 fi
2315fi
2316`
2317
2318 // Update or create the post-commit hook
2319 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2320 if err != nil {
2321 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2322 }
2323
2324 // Update or create the prepare-commit-msg hook
2325 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2326 if err != nil {
2327 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2328 }
2329
2330 return nil
2331}
2332
2333// updateOrCreateHook creates a new hook file or updates an existing one
2334// by appending the new content if it doesn't already contain it.
2335func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2336 // Check if the hook already exists
2337 buf, err := os.ReadFile(hookPath)
2338 if os.IsNotExist(err) {
2339 // Hook doesn't exist, create it
2340 err = os.WriteFile(hookPath, []byte(content), 0o755)
2341 if err != nil {
2342 return fmt.Errorf("failed to create hook: %w", err)
2343 }
2344 return nil
2345 }
2346 if err != nil {
2347 return fmt.Errorf("error reading existing hook: %w", err)
2348 }
2349
2350 // Hook exists, check if our content is already in it by looking for a distinctive line
2351 code := string(buf)
2352 if strings.Contains(code, distinctiveLine) {
2353 // Already contains our content, nothing to do
2354 return nil
2355 }
2356
2357 // Append our content to the existing hook
2358 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2359 if err != nil {
2360 return fmt.Errorf("failed to open hook for appending: %w", err)
2361 }
2362 defer f.Close()
2363
2364 // Ensure there's a newline at the end of the existing content if needed
2365 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2366 _, err = f.WriteString("\n")
2367 if err != nil {
2368 return fmt.Errorf("failed to add newline to hook: %w", err)
2369 }
2370 }
2371
2372 // Add a separator before our content
2373 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2374 if err != nil {
2375 return fmt.Errorf("failed to append to hook: %w", err)
2376 }
2377
2378 return nil
2379}
Sean McCullough138ec242025-06-02 22:42:06 +00002380
2381// GetPortMonitor returns the port monitor instance for accessing port events
2382func (a *Agent) GetPortMonitor() *PortMonitor {
2383 return a.portMonitor
2384}
Philip Zeyliger0113be52025-06-07 23:53:41 +00002385
2386// SkabandAddr returns the skaband address if configured
2387func (a *Agent) SkabandAddr() string {
2388 if a.config.SkabandClient != nil {
2389 return a.config.SkabandClient.Addr()
2390 }
2391 return ""
2392}