blob: 40bbb204147edb40ab698850e6313f62fc61b995 [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"
Autoformatter4962f152025-05-06 17:24:20 +000024 "sketch.dev/claudetool/browse"
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +000025 "sketch.dev/claudetool/codereview"
Josh Bleecher Snydera997be62025-05-07 22:52:46 +000026 "sketch.dev/claudetool/onstart"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070027 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070028 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070029 "sketch.dev/llm/conversation"
Philip Zeyliger194bfa82025-06-24 06:03:06 -070030 "sketch.dev/mcp"
Philip Zeyligerc17ffe32025-06-05 19:49:13 -070031 "sketch.dev/skabandclient"
Philip Zeyliger5f26a342025-07-04 01:30:29 +000032 "tailscale.com/portlist"
Earl Lee2e463fb2025-04-17 11:22:22 -070033)
34
35const (
36 userCancelMessage = "user requested agent to stop handling responses"
37)
38
Philip Zeyligerb7c58752025-05-01 10:10:17 -070039type MessageIterator interface {
40 // Next blocks until the next message is available. It may
41 // return nil if the underlying iterator context is done.
42 Next() *AgentMessage
43 Close()
44}
45
Earl Lee2e463fb2025-04-17 11:22:22 -070046type CodingAgent interface {
47 // Init initializes an agent inside a docker container.
48 Init(AgentInit) error
49
50 // Ready returns a channel closed after Init successfully called.
51 Ready() <-chan struct{}
52
53 // URL reports the HTTP URL of this agent.
54 URL() string
55
56 // UserMessage enqueues a message to the agent and returns immediately.
57 UserMessage(ctx context.Context, msg string)
58
Philip Zeyligerb7c58752025-05-01 10:10:17 -070059 // Returns an iterator that finishes when the context is done and
60 // starts with the given message index.
61 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070062
Philip Zeyligereab12de2025-05-14 02:35:53 +000063 // Returns an iterator that notifies of state transitions until the context is done.
64 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
65
Earl Lee2e463fb2025-04-17 11:22:22 -070066 // Loop begins the agent loop returns only when ctx is cancelled.
67 Loop(ctx context.Context)
68
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000069 // BranchPrefix returns the configured branch prefix
70 BranchPrefix() string
71
philip.zeyliger6d3de482025-06-10 19:38:14 -070072 // LinkToGitHub returns whether GitHub branch linking is enabled
73 LinkToGitHub() bool
74
Sean McCulloughedc88dc2025-04-30 02:55:01 +000075 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070076
77 CancelToolUse(toolUseID string, cause error) error
78
79 // Returns a subset of the agent's message history.
80 Messages(start int, end int) []AgentMessage
81
82 // Returns the current number of messages in the history
83 MessageCount() int
84
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070085 TotalUsage() conversation.CumulativeUsage
86 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070087
Earl Lee2e463fb2025-04-17 11:22:22 -070088 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000089 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070090
91 // Diff returns a unified diff of changes made since the agent was instantiated.
92 // If commit is non-nil, it shows the diff for just that specific commit.
93 Diff(commit *string) (string, error)
94
Philip Zeyliger49edc922025-05-14 09:45:45 -070095 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
96 // starts out as the commit where sketch started, but a user can move it if need
97 // be, for example in the case of a rebase. It is stored as a git tag.
98 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -070099
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000100 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
101 // (Typically, this is "sketch-base")
102 SketchGitBaseRef() string
103
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700104 // Slug returns the slug identifier for this session.
105 Slug() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700106
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000107 // BranchName returns the git branch name for the conversation.
108 BranchName() string
109
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700110 // IncrementRetryNumber increments the retry number for branch naming conflicts.
111 IncrementRetryNumber()
112
Earl Lee2e463fb2025-04-17 11:22:22 -0700113 // OS returns the operating system of the client.
114 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000115
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000116 // SessionID returns the unique session identifier.
117 SessionID() string
118
philip.zeyliger8773e682025-06-11 21:36:21 -0700119 // SSHConnectionString returns the SSH connection string for the container.
120 SSHConnectionString() string
121
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000122 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700123 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000124
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000125 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
126 OutstandingLLMCallCount() int
127
128 // OutstandingToolCalls returns the names of outstanding tool calls.
129 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000130 OutsideOS() string
131 OutsideHostname() string
132 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000133 GitOrigin() string
Philip Zeyliger64f60462025-06-16 13:57:10 -0700134
bankseancad67b02025-06-27 21:57:05 +0000135 // GitUsername returns the git user name from the agent config.
136 GitUsername() string
137
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700138 // PassthroughUpstream returns whether passthrough upstream is enabled.
139 PassthroughUpstream() bool
140
Philip Zeyliger64f60462025-06-16 13:57:10 -0700141 // DiffStats returns the number of lines added and removed from sketch-base to HEAD
142 DiffStats() (int, int)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000143 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
144 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700145
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700146 // IsInContainer returns true if the agent is running in a container
147 IsInContainer() bool
148 // FirstMessageIndex returns the index of the first message in the current conversation
149 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700150
151 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700152 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
153 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700154
155 // CompactConversation compacts the current conversation by generating a summary
156 // and restarting the conversation with that summary as the initial context
157 CompactConversation(ctx context.Context) error
Philip Zeyligerda623b52025-07-04 01:12:38 +0000158
Philip Zeyliger0113be52025-06-07 23:53:41 +0000159 // SkabandAddr returns the skaband address if configured
160 SkabandAddr() string
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000161
162 // GetPorts returns the cached list of open TCP ports
163 GetPorts() []portlist.Port
banksean5ab8fb82025-07-09 12:34:55 -0700164
165 // TokenContextWindow returns the TokenContextWindow size of the model the agent is using.
166 TokenContextWindow() int
Earl Lee2e463fb2025-04-17 11:22:22 -0700167}
168
169type CodingAgentMessageType string
170
171const (
172 UserMessageType CodingAgentMessageType = "user"
173 AgentMessageType CodingAgentMessageType = "agent"
174 ErrorMessageType CodingAgentMessageType = "error"
175 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
176 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700177 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
178 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
179 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000180 PortMessageType CodingAgentMessageType = "port" // for port monitoring events
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +0000181 SlugMessageType CodingAgentMessageType = "slug" // for slug updates
Earl Lee2e463fb2025-04-17 11:22:22 -0700182
183 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
184)
185
186type AgentMessage struct {
187 Type CodingAgentMessageType `json:"type"`
188 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
189 EndOfTurn bool `json:"end_of_turn"`
190
191 Content string `json:"content"`
192 ToolName string `json:"tool_name,omitempty"`
193 ToolInput string `json:"input,omitempty"`
194 ToolResult string `json:"tool_result,omitempty"`
195 ToolError bool `json:"tool_error,omitempty"`
196 ToolCallId string `json:"tool_call_id,omitempty"`
197
198 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
199 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
200
Sean McCulloughd9f13372025-04-21 15:08:49 -0700201 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
202 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
203
Earl Lee2e463fb2025-04-17 11:22:22 -0700204 // Commits is a list of git commits for a commit message
205 Commits []*GitCommit `json:"commits,omitempty"`
206
207 Timestamp time.Time `json:"timestamp"`
208 ConversationID string `json:"conversation_id"`
209 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700210 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700211
212 // Message timing information
213 StartTime *time.Time `json:"start_time,omitempty"`
214 EndTime *time.Time `json:"end_time,omitempty"`
215 Elapsed *time.Duration `json:"elapsed,omitempty"`
216
217 // Turn duration - the time taken for a complete agent turn
218 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
219
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000220 // HideOutput indicates that this message should not be rendered in the UI.
221 // This is useful for subconversations that generate output that shouldn't be shown to the user.
222 HideOutput bool `json:"hide_output,omitempty"`
223
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700224 // TodoContent contains the agent's todo file content when it has changed
225 TodoContent *string `json:"todo_content,omitempty"`
226
Earl Lee2e463fb2025-04-17 11:22:22 -0700227 Idx int `json:"idx"`
228}
229
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000230// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700231func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700232 if convo == nil {
233 m.ConversationID = ""
234 m.ParentConversationID = nil
235 return
236 }
237 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000238 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700239 if convo.Parent != nil {
240 m.ParentConversationID = &convo.Parent.ID
241 }
242}
243
Earl Lee2e463fb2025-04-17 11:22:22 -0700244// GitCommit represents a single git commit for a commit message
245type GitCommit struct {
246 Hash string `json:"hash"` // Full commit hash
247 Subject string `json:"subject"` // Commit subject line
248 Body string `json:"body"` // Full commit message body
249 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
250}
251
252// ToolCall represents a single tool call within an agent message
253type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700254 Name string `json:"name"`
255 Input string `json:"input"`
256 ToolCallId string `json:"tool_call_id"`
257 ResultMessage *AgentMessage `json:"result_message,omitempty"`
258 Args string `json:"args,omitempty"`
259 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700260}
261
262func (a *AgentMessage) Attr() slog.Attr {
263 var attrs []any = []any{
264 slog.String("type", string(a.Type)),
265 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700266 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700267 if a.EndOfTurn {
268 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
269 }
270 if a.Content != "" {
271 attrs = append(attrs, slog.String("content", a.Content))
272 }
273 if a.ToolName != "" {
274 attrs = append(attrs, slog.String("tool_name", a.ToolName))
275 }
276 if a.ToolInput != "" {
277 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
278 }
279 if a.Elapsed != nil {
280 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
281 }
282 if a.TurnDuration != nil {
283 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
284 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700285 if len(a.ToolResult) > 0 {
286 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700287 }
288 if a.ToolError {
289 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
290 }
291 if len(a.ToolCalls) > 0 {
292 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
293 for i, tc := range a.ToolCalls {
294 toolCallAttrs = append(toolCallAttrs, slog.Group(
295 fmt.Sprintf("tool_call_%d", i),
296 slog.String("name", tc.Name),
297 slog.String("input", tc.Input),
298 ))
299 }
300 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
301 }
302 if a.ConversationID != "" {
303 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
304 }
305 if a.ParentConversationID != nil {
306 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
307 }
308 if a.Usage != nil && !a.Usage.IsZero() {
309 attrs = append(attrs, a.Usage.Attr())
310 }
311 // TODO: timestamp, convo ids, idx?
312 return slog.Group("agent_message", attrs...)
313}
314
315func errorMessage(err error) AgentMessage {
316 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
317 if os.Getenv(("DEBUG")) == "1" {
318 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
319 }
320
321 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
322}
323
324func budgetMessage(err error) AgentMessage {
325 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
326}
327
328// ConvoInterface defines the interface for conversation interactions
329type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700330 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700331 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700332 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700333 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700334 SendMessage(message llm.Message) (*llm.Response, error)
335 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700336 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000337 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700338 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700339 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700340 SubConvoWithHistory() *conversation.Convo
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700341 DebugJSON() ([]byte, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700342}
343
Philip Zeyligerf2872992025-05-22 10:35:28 -0700344// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700345// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700346// any time we notice we need to.
347type AgentGitState struct {
348 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700349 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700350 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000351 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700352 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700353 slug string // Human-readable session identifier
354 retryNumber int // Number to append when branch conflicts occur
Philip Zeyliger64f60462025-06-16 13:57:10 -0700355 linesAdded int // Lines added from sketch-base to HEAD
356 linesRemoved int // Lines removed from sketch-base to HEAD
Philip Zeyligerf2872992025-05-22 10:35:28 -0700357}
358
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700359func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700360 ags.mu.Lock()
361 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700362 if ags.slug != slug {
363 ags.retryNumber = 0
364 }
365 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700366}
367
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700368func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700369 ags.mu.Lock()
370 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700371 return ags.slug
372}
373
374func (ags *AgentGitState) IncrementRetryNumber() {
375 ags.mu.Lock()
376 defer ags.mu.Unlock()
377 ags.retryNumber++
378}
379
Philip Zeyliger64f60462025-06-16 13:57:10 -0700380func (ags *AgentGitState) DiffStats() (int, int) {
381 ags.mu.Lock()
382 defer ags.mu.Unlock()
383 return ags.linesAdded, ags.linesRemoved
384}
385
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700386// HasSeenCommits returns true if any commits have been processed
387func (ags *AgentGitState) HasSeenCommits() bool {
388 ags.mu.Lock()
389 defer ags.mu.Unlock()
390 return len(ags.seenCommits) > 0
391}
392
393func (ags *AgentGitState) RetryNumber() int {
394 ags.mu.Lock()
395 defer ags.mu.Unlock()
396 return ags.retryNumber
397}
398
399func (ags *AgentGitState) BranchName(prefix string) string {
400 ags.mu.Lock()
401 defer ags.mu.Unlock()
402 return ags.branchNameLocked(prefix)
403}
404
405func (ags *AgentGitState) branchNameLocked(prefix string) string {
406 if ags.slug == "" {
407 return ""
408 }
409 if ags.retryNumber == 0 {
410 return prefix + ags.slug
411 }
412 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700413}
414
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000415func (ags *AgentGitState) Upstream() string {
416 ags.mu.Lock()
417 defer ags.mu.Unlock()
418 return ags.upstream
419}
420
Earl Lee2e463fb2025-04-17 11:22:22 -0700421type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700422 convo ConvoInterface
423 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700424 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700425 workingDir string
426 repoRoot string // workingDir may be a subdir of repoRoot
427 url string
428 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000429 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700430 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000431 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700432 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700433 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000434 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700435 // State machine to track agent state
436 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000437 // Outside information
438 outsideHostname string
439 outsideOS string
440 outsideWorkingDir string
Philip Zeyliger194bfa82025-06-24 06:03:06 -0700441 // MCP manager for handling MCP server connections
442 mcpManager *mcp.MCPManager
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000443 // Port monitor for tracking TCP ports
444 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700445
446 // Time when the current turn started (reset at the beginning of InnerLoop)
447 startOfTurn time.Time
448
449 // Inbox - for messages from the user to the agent.
450 // sent on by UserMessage
451 // . e.g. when user types into the chat textarea
452 // read from by GatherMessages
453 inbox chan string
454
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000455 // protects cancelTurn
456 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700457 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000458 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700459
460 // protects following
461 mu sync.Mutex
462
463 // Stores all messages for this agent
464 history []AgentMessage
465
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700466 // Iterators add themselves here when they're ready to be notified of new messages.
467 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700468
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000469 // Track outstanding LLM call IDs
470 outstandingLLMCalls map[string]struct{}
471
472 // Track outstanding tool calls by ID with their names
473 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700474}
475
banksean5ab8fb82025-07-09 12:34:55 -0700476// TokenContextWindow implements CodingAgent.
477func (a *Agent) TokenContextWindow() int {
478 return a.config.Service.TokenContextWindow()
479}
480
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700481// GetConvo returns the conversation interface for debugging purposes.
482func (a *Agent) GetConvo() ConvoInterface {
483 return a.convo
484}
485
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700486// NewIterator implements CodingAgent.
487func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
488 a.mu.Lock()
489 defer a.mu.Unlock()
490
491 return &MessageIteratorImpl{
492 agent: a,
493 ctx: ctx,
494 nextMessageIdx: nextMessageIdx,
495 ch: make(chan *AgentMessage, 100),
496 }
497}
498
499type MessageIteratorImpl struct {
500 agent *Agent
501 ctx context.Context
502 nextMessageIdx int
503 ch chan *AgentMessage
504 subscribed bool
505}
506
507func (m *MessageIteratorImpl) Close() {
508 m.agent.mu.Lock()
509 defer m.agent.mu.Unlock()
510 // Delete ourselves from the subscribers list
511 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
512 return x == m.ch
513 })
514 close(m.ch)
515}
516
517func (m *MessageIteratorImpl) Next() *AgentMessage {
518 // We avoid subscription at creation to let ourselves catch up to "current state"
519 // before subscribing.
520 if !m.subscribed {
521 m.agent.mu.Lock()
522 if m.nextMessageIdx < len(m.agent.history) {
523 msg := &m.agent.history[m.nextMessageIdx]
524 m.nextMessageIdx++
525 m.agent.mu.Unlock()
526 return msg
527 }
528 // The next message doesn't exist yet, so let's subscribe
529 m.agent.subscribers = append(m.agent.subscribers, m.ch)
530 m.subscribed = true
531 m.agent.mu.Unlock()
532 }
533
534 for {
535 select {
536 case <-m.ctx.Done():
537 m.agent.mu.Lock()
538 // Delete ourselves from the subscribers list
539 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
540 return x == m.ch
541 })
542 m.subscribed = false
543 m.agent.mu.Unlock()
544 return nil
545 case msg, ok := <-m.ch:
546 if !ok {
547 // Close may have been called
548 return nil
549 }
550 if msg.Idx == m.nextMessageIdx {
551 m.nextMessageIdx++
552 return msg
553 }
554 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
555 panic("out of order message")
556 }
557 }
558}
559
Sean McCulloughd9d45812025-04-30 16:53:41 -0700560// Assert that Agent satisfies the CodingAgent interface.
561var _ CodingAgent = &Agent{}
562
563// StateName implements CodingAgent.
564func (a *Agent) CurrentStateName() string {
565 if a.stateMachine == nil {
566 return ""
567 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000568 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700569}
570
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700571// CurrentTodoContent returns the current todo list data as JSON.
572// It returns an empty string if no todos exist.
573func (a *Agent) CurrentTodoContent() string {
574 todoPath := claudetool.TodoFilePath(a.config.SessionID)
575 content, err := os.ReadFile(todoPath)
576 if err != nil {
577 return ""
578 }
579 return string(content)
580}
581
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700582// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
583func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
584 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.
585
586IMPORTANT: 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.
587
588Please create a detailed summary that includes:
589
5901. **User's Request**: What did the user originally ask me to do? What was their goal?
591
5922. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
593
5943. **Key Technical Decisions**: What important technical choices were made during our work and why?
595
5964. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
597
5985. **Next Steps**: What still needs to be done to complete the user's request?
599
6006. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
601
602Focus 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.
603
604Reply with ONLY the summary content - no meta-commentary about creating the summary.`
605
606 userMessage := llm.UserStringMessage(msg)
607 // Use a subconversation with history to get the summary
608 // TODO: We don't have any tools here, so we should have enough tokens
609 // to capture a summary, but we may need to modify the history (e.g., remove
610 // TODO data) to save on some tokens.
611 convo := a.convo.SubConvoWithHistory()
612
613 // Modify the system prompt to provide context about the original task
614 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000615 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 -0700616
617Your 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.
618
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000619Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700620
621 resp, err := convo.SendMessage(userMessage)
622 if err != nil {
623 a.pushToOutbox(ctx, errorMessage(err))
624 return "", err
625 }
626 textContent := collectTextContent(resp)
627
628 // Restore original system prompt (though this subconvo will be discarded)
629 convo.SystemPrompt = originalSystemPrompt
630
631 return textContent, nil
632}
633
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000634// dumpMessageHistoryToTmp dumps the agent's entire message history to /tmp as JSON
635// and returns the filename
636func (a *Agent) dumpMessageHistoryToTmp(ctx context.Context) (string, error) {
637 // Create a filename based on session ID and timestamp
638 timestamp := time.Now().Format("20060102-150405")
639 filename := fmt.Sprintf("/tmp/sketch-messages-%s-%s.json", a.config.SessionID, timestamp)
640
641 // Marshal the entire message history to JSON
642 jsonData, err := json.MarshalIndent(a.history, "", " ")
643 if err != nil {
644 return "", fmt.Errorf("failed to marshal message history: %w", err)
645 }
646
647 // Write to file
Autoformatter3ad8c8d2025-07-15 21:05:23 +0000648 if err := os.WriteFile(filename, jsonData, 0o644); err != nil {
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000649 return "", fmt.Errorf("failed to write message history to %s: %w", filename, err)
650 }
651
652 slog.InfoContext(ctx, "Dumped message history to file", "filename", filename, "message_count", len(a.history))
653 return filename, nil
654}
655
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700656// CompactConversation compacts the current conversation by generating a summary
657// and restarting the conversation with that summary as the initial context
658func (a *Agent) CompactConversation(ctx context.Context) error {
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000659 // Dump the entire message history to /tmp as JSON before compacting
660 dumpFile, err := a.dumpMessageHistoryToTmp(ctx)
661 if err != nil {
662 slog.WarnContext(ctx, "Failed to dump message history to /tmp", "error", err)
663 // Continue with compaction even if dump fails
664 }
665
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700666 summary, err := a.generateConversationSummary(ctx)
667 if err != nil {
668 return fmt.Errorf("failed to generate conversation summary: %w", err)
669 }
670
671 a.mu.Lock()
672
673 // Get usage information before resetting conversation
674 lastUsage := a.convo.LastUsage()
675 contextWindow := a.config.Service.TokenContextWindow()
676 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
677
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000678 // Preserve cumulative usage across compaction
679 cumulativeUsage := a.convo.CumulativeUsage()
680
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700681 // Reset conversation state but keep all other state (git, working dir, etc.)
682 a.firstMessageIndex = len(a.history)
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000683 a.convo = a.initConvoWithUsage(&cumulativeUsage)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700684
685 a.mu.Unlock()
686
687 // Create informative compaction message with token details
688 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
689 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
690 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
691
692 a.pushToOutbox(ctx, AgentMessage{
693 Type: CompactMessageType,
694 Content: compactionMsg,
695 })
696
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000697 // Create the message content with dump file information if available
698 var messageContent string
699 if dumpFile != "" {
700 messageContent = fmt.Sprintf("Here's a summary of our previous work:\n\n%s\n\nThe complete message history has been dumped to %s for your reference if needed.\n\nPlease continue with the work based on this summary.", summary, dumpFile)
701 } else {
702 messageContent = fmt.Sprintf("Here's a summary of our previous work:\n\n%s\n\nPlease continue with the work based on this summary.", summary)
703 }
704
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700705 a.pushToOutbox(ctx, AgentMessage{
706 Type: UserMessageType,
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000707 Content: messageContent,
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700708 })
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000709 a.inbox <- messageContent
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700710
711 return nil
712}
713
Earl Lee2e463fb2025-04-17 11:22:22 -0700714func (a *Agent) URL() string { return a.url }
715
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000716// GetPorts returns the cached list of open TCP ports.
717func (a *Agent) GetPorts() []portlist.Port {
718 if a.portMonitor == nil {
719 return nil
720 }
721 return a.portMonitor.GetPorts()
722}
723
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000724// BranchName returns the git branch name for the conversation.
725func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700726 return a.gitState.BranchName(a.config.BranchPrefix)
727}
728
729// Slug returns the slug identifier for this conversation.
730func (a *Agent) Slug() string {
731 return a.gitState.Slug()
732}
733
734// IncrementRetryNumber increments the retry number for branch naming conflicts
735func (a *Agent) IncrementRetryNumber() {
736 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000737}
738
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000739// OutstandingLLMCallCount returns the number of outstanding LLM calls.
740func (a *Agent) OutstandingLLMCallCount() int {
741 a.mu.Lock()
742 defer a.mu.Unlock()
743 return len(a.outstandingLLMCalls)
744}
745
746// OutstandingToolCalls returns the names of outstanding tool calls.
747func (a *Agent) OutstandingToolCalls() []string {
748 a.mu.Lock()
749 defer a.mu.Unlock()
750
751 tools := make([]string, 0, len(a.outstandingToolCalls))
752 for _, toolName := range a.outstandingToolCalls {
753 tools = append(tools, toolName)
754 }
755 return tools
756}
757
Earl Lee2e463fb2025-04-17 11:22:22 -0700758// OS returns the operating system of the client.
759func (a *Agent) OS() string {
760 return a.config.ClientGOOS
761}
762
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000763func (a *Agent) SessionID() string {
764 return a.config.SessionID
765}
766
philip.zeyliger8773e682025-06-11 21:36:21 -0700767// SSHConnectionString returns the SSH connection string for the container.
768func (a *Agent) SSHConnectionString() string {
769 return a.config.SSHConnectionString
770}
771
Philip Zeyliger18532b22025-04-23 21:11:46 +0000772// OutsideOS returns the operating system of the outside system.
773func (a *Agent) OutsideOS() string {
774 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000775}
776
Philip Zeyliger18532b22025-04-23 21:11:46 +0000777// OutsideHostname returns the hostname of the outside system.
778func (a *Agent) OutsideHostname() string {
779 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000780}
781
Philip Zeyliger18532b22025-04-23 21:11:46 +0000782// OutsideWorkingDir returns the working directory on the outside system.
783func (a *Agent) OutsideWorkingDir() string {
784 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000785}
786
787// GitOrigin returns the URL of the git remote 'origin' if it exists.
788func (a *Agent) GitOrigin() string {
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +0000789 return a.config.OriginalGitOrigin
Philip Zeyligerd1402952025-04-23 03:54:37 +0000790}
791
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700792// PassthroughUpstream returns whether passthrough upstream is enabled.
793func (a *Agent) PassthroughUpstream() bool {
794 return a.config.PassthroughUpstream
795}
796
bankseancad67b02025-06-27 21:57:05 +0000797// GitUsername returns the git user name from the agent config.
798func (a *Agent) GitUsername() string {
799 return a.config.GitUsername
800}
801
Philip Zeyliger64f60462025-06-16 13:57:10 -0700802// DiffStats returns the number of lines added and removed from sketch-base to HEAD
803func (a *Agent) DiffStats() (int, int) {
804 return a.gitState.DiffStats()
805}
806
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000807func (a *Agent) OpenBrowser(url string) {
808 if !a.IsInContainer() {
809 browser.Open(url)
810 return
811 }
812 // We're in Docker, need to send a request to the Git server
813 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700814 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000815 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700816 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000817 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700818 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000819 return
820 }
821 defer resp.Body.Close()
822 if resp.StatusCode == http.StatusOK {
823 return
824 }
825 body, _ := io.ReadAll(resp.Body)
826 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
827}
828
Sean McCullough96b60dd2025-04-30 09:49:10 -0700829// CurrentState returns the current state of the agent's state machine.
830func (a *Agent) CurrentState() State {
831 return a.stateMachine.CurrentState()
832}
833
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700834func (a *Agent) IsInContainer() bool {
835 return a.config.InDocker
836}
837
838func (a *Agent) FirstMessageIndex() int {
839 a.mu.Lock()
840 defer a.mu.Unlock()
841 return a.firstMessageIndex
842}
843
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700844// SetSlug sets a human-readable identifier for the conversation.
845func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700846 a.mu.Lock()
847 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700848
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700849 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000850 convo, ok := a.convo.(*conversation.Convo)
851 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700852 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000853 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700854}
855
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000856// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700857func (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 +0000858 // Track the tool call
859 a.mu.Lock()
860 a.outstandingToolCalls[id] = toolName
861 a.mu.Unlock()
862}
863
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700864// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
865// If there's only one element in the array and it's a text type, it returns that text directly.
866// It also processes nested ToolResult arrays recursively.
867func contentToString(contents []llm.Content) string {
868 if len(contents) == 0 {
869 return ""
870 }
871
872 // If there's only one element and it's a text type, return it directly
873 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
874 return contents[0].Text
875 }
876
877 // Otherwise, concatenate all text content
878 var result strings.Builder
879 for _, content := range contents {
880 if content.Type == llm.ContentTypeText {
881 result.WriteString(content.Text)
882 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
883 // Recursively process nested tool results
884 result.WriteString(contentToString(content.ToolResult))
885 }
886 }
887
888 return result.String()
889}
890
Earl Lee2e463fb2025-04-17 11:22:22 -0700891// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700892func (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 +0000893 // Remove the tool call from outstanding calls
894 a.mu.Lock()
895 delete(a.outstandingToolCalls, toolID)
896 a.mu.Unlock()
897
Earl Lee2e463fb2025-04-17 11:22:22 -0700898 m := AgentMessage{
899 Type: ToolUseMessageType,
900 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700901 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700902 ToolError: content.ToolError,
903 ToolName: toolName,
904 ToolInput: string(toolInput),
905 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700906 StartTime: content.ToolUseStartTime,
907 EndTime: content.ToolUseEndTime,
Earl Lee2e463fb2025-04-17 11:22:22 -0700908 }
909
910 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700911 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
912 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700913 m.Elapsed = &elapsed
914 }
915
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700916 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700917 a.pushToOutbox(ctx, m)
918}
919
920// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700921func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000922 a.mu.Lock()
923 defer a.mu.Unlock()
924 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700925 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
926}
927
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700928// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700929// that need to be displayed (as well as tool calls that we send along when
930// they're done). (It would be reasonable to also mention tool calls when they're
931// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700932func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000933 // Remove the LLM call from outstanding calls
934 a.mu.Lock()
935 delete(a.outstandingLLMCalls, id)
936 a.mu.Unlock()
937
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700938 if resp == nil {
939 // LLM API call failed
940 m := AgentMessage{
941 Type: ErrorMessageType,
942 Content: "API call failed, type 'continue' to try again",
943 }
944 m.SetConvo(convo)
945 a.pushToOutbox(ctx, m)
946 return
947 }
948
Earl Lee2e463fb2025-04-17 11:22:22 -0700949 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700950 if convo.Parent == nil { // subconvos never end the turn
951 switch resp.StopReason {
952 case llm.StopReasonToolUse:
953 // Check whether any of the tool calls are for tools that should end the turn
954 ToolSearch:
955 for _, part := range resp.Content {
956 if part.Type != llm.ContentTypeToolUse {
957 continue
958 }
Sean McCullough021557a2025-05-05 23:20:53 +0000959 // Find the tool by name
960 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700961 if tool.Name == part.ToolName {
962 endOfTurn = tool.EndsTurn
963 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000964 }
965 }
Sean McCullough021557a2025-05-05 23:20:53 +0000966 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700967 default:
968 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000969 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700970 }
971 m := AgentMessage{
972 Type: AgentMessageType,
973 Content: collectTextContent(resp),
974 EndOfTurn: endOfTurn,
975 Usage: &resp.Usage,
976 StartTime: resp.StartTime,
977 EndTime: resp.EndTime,
978 }
979
980 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700981 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700982 var toolCalls []ToolCall
983 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700984 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700985 toolCalls = append(toolCalls, ToolCall{
986 Name: part.ToolName,
987 Input: string(part.ToolInput),
988 ToolCallId: part.ID,
989 })
990 }
991 }
992 m.ToolCalls = toolCalls
993 }
994
995 // Calculate the elapsed time if both start and end times are set
996 if resp.StartTime != nil && resp.EndTime != nil {
997 elapsed := resp.EndTime.Sub(*resp.StartTime)
998 m.Elapsed = &elapsed
999 }
1000
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -07001001 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -07001002 a.pushToOutbox(ctx, m)
1003}
1004
1005// WorkingDir implements CodingAgent.
1006func (a *Agent) WorkingDir() string {
1007 return a.workingDir
1008}
1009
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001010// RepoRoot returns the git repository root directory.
1011func (a *Agent) RepoRoot() string {
1012 return a.repoRoot
1013}
1014
Earl Lee2e463fb2025-04-17 11:22:22 -07001015// MessageCount implements CodingAgent.
1016func (a *Agent) MessageCount() int {
1017 a.mu.Lock()
1018 defer a.mu.Unlock()
1019 return len(a.history)
1020}
1021
1022// Messages implements CodingAgent.
1023func (a *Agent) Messages(start int, end int) []AgentMessage {
1024 a.mu.Lock()
1025 defer a.mu.Unlock()
1026 return slices.Clone(a.history[start:end])
1027}
1028
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001029// ShouldCompact checks if the conversation should be compacted based on token usage
1030func (a *Agent) ShouldCompact() bool {
1031 // Get the threshold from environment variable, default to 0.94 (94%)
1032 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
1033 // and a little bit of buffer.)
1034 thresholdRatio := 0.94
1035 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
1036 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
1037 thresholdRatio = parsed
1038 }
1039 }
1040
1041 // Get the most recent usage to check current context size
1042 lastUsage := a.convo.LastUsage()
1043
1044 if lastUsage.InputTokens == 0 {
1045 // No API calls made yet
1046 return false
1047 }
1048
1049 // Calculate the current context size from the last API call
1050 // This includes all tokens that were part of the input context:
1051 // - Input tokens (user messages, system prompt, conversation history)
1052 // - Cache read tokens (cached parts of the context)
1053 // - Cache creation tokens (new parts being cached)
1054 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
1055
1056 // Get the service's token context window
1057 service := a.config.Service
1058 contextWindow := service.TokenContextWindow()
1059
1060 // Calculate threshold
1061 threshold := uint64(float64(contextWindow) * thresholdRatio)
1062
1063 // Check if we've exceeded the threshold
1064 return currentContextSize >= threshold
1065}
1066
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001067func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -07001068 return a.originalBudget
1069}
1070
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001071// Upstream returns the upstream branch for git work
1072func (a *Agent) Upstream() string {
1073 return a.gitState.Upstream()
1074}
1075
Earl Lee2e463fb2025-04-17 11:22:22 -07001076// AgentConfig contains configuration for creating a new Agent.
1077type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001078 Context context.Context
1079 Service llm.Service
1080 Budget conversation.Budget
1081 GitUsername string
1082 GitEmail string
1083 SessionID string
1084 ClientGOOS string
1085 ClientGOARCH string
1086 InDocker bool
1087 OneShot bool
1088 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +00001089 // Outside information
1090 OutsideHostname string
1091 OutsideOS string
1092 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001093
1094 // Outtie's HTTP to, e.g., open a browser
1095 OutsideHTTP string
1096 // Outtie's Git server
1097 GitRemoteAddr string
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001098 // Original git origin URL from host repository, if any
1099 OriginalGitOrigin string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001100 // Upstream branch for git work
1101 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001102 // Commit to checkout from Outtie
1103 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001104 // Prefix for git branches created by sketch
1105 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001106 // LinkToGitHub enables GitHub branch linking in UI
1107 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001108 // SSH connection string for connecting to the container
1109 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001110 // Skaband client for session history (optional)
1111 SkabandClient *skabandclient.SkabandClient
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001112 // MCP server configurations
1113 MCPServers []string
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001114 // Timeout configuration for bash tool
1115 BashTimeouts *claudetool.Timeouts
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001116 // PassthroughUpstream configures upstream remote for passthrough to innie
1117 PassthroughUpstream bool
Earl Lee2e463fb2025-04-17 11:22:22 -07001118}
1119
1120// NewAgent creates a new Agent.
1121// It is not usable until Init() is called.
1122func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001123 // Set default branch prefix if not specified
1124 if config.BranchPrefix == "" {
1125 config.BranchPrefix = "sketch/"
1126 }
1127
Earl Lee2e463fb2025-04-17 11:22:22 -07001128 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001129 config: config,
1130 ready: make(chan struct{}),
1131 inbox: make(chan string, 100),
1132 subscribers: make([]chan *AgentMessage, 0),
1133 startedAt: time.Now(),
1134 originalBudget: config.Budget,
1135 gitState: AgentGitState{
1136 seenCommits: make(map[string]bool),
1137 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001138 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001139 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001140 outsideHostname: config.OutsideHostname,
1141 outsideOS: config.OutsideOS,
1142 outsideWorkingDir: config.OutsideWorkingDir,
1143 outstandingLLMCalls: make(map[string]struct{}),
1144 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001145 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001146 workingDir: config.WorkingDir,
1147 outsideHTTP: config.OutsideHTTP,
Philip Zeyligerda623b52025-07-04 01:12:38 +00001148
1149 mcpManager: mcp.NewMCPManager(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001150 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001151
1152 // Initialize port monitor with 5-second interval
1153 agent.portMonitor = NewPortMonitor(agent, 5*time.Second)
1154
Earl Lee2e463fb2025-04-17 11:22:22 -07001155 return agent
1156}
1157
1158type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001159 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001160
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001161 InDocker bool
1162 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001163}
1164
1165func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001166 if a.convo != nil {
1167 return fmt.Errorf("Agent.Init: already initialized")
1168 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001169 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001170 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001171
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001172 // If a remote + commit was specified, clone it.
1173 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001174 if _, err := os.Stat("/app/.git"); err != nil {
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00001175 slog.InfoContext(ctx, "cloning git repo", "commit", a.config.Commit)
1176 // TODO: --reference-if-able instead?
1177 cmd := exec.CommandContext(ctx, "git", "clone", "--reference", "/git-ref", a.gitState.gitRemoteAddr, "/app")
1178 if out, err := cmd.CombinedOutput(); err != nil {
1179 return fmt.Errorf("failed to clone repository from %s: %s: %w", a.gitState.gitRemoteAddr, out, err)
1180 }
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001181 }
1182 }
1183
1184 if a.workingDir != "" {
1185 err := os.Chdir(a.workingDir)
1186 if err != nil {
1187 return fmt.Errorf("failed to change working directory to %s: %w", a.workingDir, err)
1188 }
1189 }
1190
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001191 if !ini.NoGit {
Philip Zeyligeraccf37c2025-07-18 07:29:19 -07001192 if a.gitState.gitRemoteAddr != "" {
1193 if err := upsertRemoteOrigin(ctx, "/app", a.gitState.gitRemoteAddr); err != nil {
1194 return err
1195 }
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001196 }
Philip Zeyligere1c8b7b2025-07-03 14:50:26 -07001197
1198 // Configure git user settings
1199 if a.config.GitEmail != "" {
1200 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.email", a.config.GitEmail)
1201 cmd.Dir = a.workingDir
1202 if out, err := cmd.CombinedOutput(); err != nil {
1203 return fmt.Errorf("git config --global user.email: %s: %v", out, err)
1204 }
1205 }
1206 if a.config.GitUsername != "" {
1207 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.name", a.config.GitUsername)
1208 cmd.Dir = a.workingDir
1209 if out, err := cmd.CombinedOutput(); err != nil {
1210 return fmt.Errorf("git config --global user.name: %s: %v", out, err)
1211 }
1212 }
1213 // Configure git http.postBuffer
1214 cmd := exec.CommandContext(ctx, "git", "config", "--global", "http.postBuffer", "524288000")
1215 cmd.Dir = a.workingDir
1216 if out, err := cmd.CombinedOutput(); err != nil {
1217 return fmt.Errorf("git config --global http.postBuffer: %s: %v", out, err)
1218 }
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001219
1220 // Configure passthrough upstream if enabled
1221 if a.config.PassthroughUpstream {
1222 if err := a.configurePassthroughUpstream(ctx); err != nil {
1223 return fmt.Errorf("failed to configure passthrough upstream: %w", err)
1224 }
1225 }
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001226 }
1227
Philip Zeyligerf2872992025-05-22 10:35:28 -07001228 // If a commit was specified, we fetch and reset to it.
1229 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001230 slog.InfoContext(ctx, "updating git repo", "commit", a.config.Commit)
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001231
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001232 cmd := exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001233 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001234 if out, err := cmd.CombinedOutput(); err != nil {
1235 return fmt.Errorf("git fetch: %s: %w", out, err)
1236 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001237 // The -B resets the branch if it already exists (or creates it if it doesn't)
1238 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001239 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001240 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1241 // Remove git hooks if they exist and retry
1242 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001243 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001244 if _, statErr := os.Stat(hookPath); statErr == nil {
1245 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1246 slog.String("error", err.Error()),
1247 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001248 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001249 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1250 }
1251
1252 // Retry the checkout operation
Philip Zeyliger1417b692025-06-12 11:07:04 -07001253 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001254 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001255 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001256 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 +01001257 }
1258 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001259 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001260 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001261 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001262 } else if a.IsInContainer() {
1263 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1264 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1265 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1266 cmd.Dir = a.workingDir
1267 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1268 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1269 }
1270 } else {
1271 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001272 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001273
1274 if ini.HostAddr != "" {
1275 a.url = "http://" + ini.HostAddr
1276 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001277
1278 if !ini.NoGit {
1279 repoRoot, err := repoRoot(ctx, a.workingDir)
1280 if err != nil {
1281 return fmt.Errorf("repoRoot: %w", err)
1282 }
1283 a.repoRoot = repoRoot
1284
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001285 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001286 if err := setupGitHooks(a.repoRoot); err != nil {
1287 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1288 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001289 }
1290
philz24613202025-07-15 20:56:21 -07001291 // Check if we have any commits, and if not, create an empty initial commit
1292 cmd := exec.CommandContext(ctx, "git", "rev-list", "--all", "--count")
1293 cmd.Dir = repoRoot
1294 countOut, err := cmd.CombinedOutput()
1295 if err != nil {
1296 return fmt.Errorf("git rev-list --all --count: %s: %w", countOut, err)
1297 }
1298 commitCount := strings.TrimSpace(string(countOut))
1299 if commitCount == "0" {
1300 slog.Info("No commits found, creating empty initial commit")
1301 cmd = exec.CommandContext(ctx, "git", "commit", "--allow-empty", "-m", "Initial empty commit")
1302 cmd.Dir = repoRoot
1303 if commitOut, err := cmd.CombinedOutput(); err != nil {
1304 return fmt.Errorf("git commit --allow-empty: %s: %w", commitOut, err)
1305 }
1306 }
1307
1308 cmd = exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
Philip Zeyliger49edc922025-05-14 09:45:45 -07001309 cmd.Dir = repoRoot
1310 if out, err := cmd.CombinedOutput(); err != nil {
1311 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1312 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001313
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001314 slog.Info("running codebase analysis")
1315 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1316 if err != nil {
1317 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001318 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001319 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001320
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001321 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001322 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001323 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001324 }
1325 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001326
Earl Lee2e463fb2025-04-17 11:22:22 -07001327 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001328 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001329 a.convo = a.initConvo()
1330 close(a.ready)
1331 return nil
1332}
1333
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001334//go:embed agent_system_prompt.txt
1335var agentSystemPrompt string
1336
Earl Lee2e463fb2025-04-17 11:22:22 -07001337// initConvo initializes the conversation.
1338// It must not be called until all agent fields are initialized,
1339// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001340func (a *Agent) initConvo() *conversation.Convo {
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001341 return a.initConvoWithUsage(nil)
1342}
1343
1344// initConvoWithUsage initializes the conversation with optional preserved usage.
1345func (a *Agent) initConvoWithUsage(usage *conversation.CumulativeUsage) *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001346 ctx := a.config.Context
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001347 convo := conversation.New(ctx, a.config.Service, usage)
Earl Lee2e463fb2025-04-17 11:22:22 -07001348 convo.PromptCaching = true
1349 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001350 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001351 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001352
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001353 bashTool := &claudetool.BashTool{
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001354 EnableJITInstall: claudetool.EnableBashToolJITInstall,
1355 Timeouts: a.config.BashTimeouts,
1356 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001357
Earl Lee2e463fb2025-04-17 11:22:22 -07001358 // Register all tools with the conversation
1359 // When adding, removing, or modifying tools here, double-check that the termui tool display
1360 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001361
1362 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001363 _, supportsScreenshots := a.config.Service.(*ant.Service)
1364 var bTools []*llm.Tool
1365 var browserCleanup func()
1366
1367 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1368 // Add cleanup function to context cancel
1369 go func() {
1370 <-a.config.Context.Done()
1371 browserCleanup()
1372 }()
1373 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001374
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001375 convo.Tools = []*llm.Tool{
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001376 bashTool.Tool(), claudetool.Keyword, claudetool.Patch(a.patchCallback),
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001377 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, a.commitMessageStyleTool(), makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001378 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001379 }
1380
Josh Bleecher Snyderb529e732025-05-07 22:06:46 +00001381 // One-shot mode is non-interactive, multiple choice requires human response
1382 if !a.config.OneShot {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001383 convo.Tools = append(convo.Tools, multipleChoiceTool)
Earl Lee2e463fb2025-04-17 11:22:22 -07001384 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001385
1386 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001387
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001388 // Add MCP tools if configured
1389 if len(a.config.MCPServers) > 0 {
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001390
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001391 slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers))
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001392 serverConfigs, parseErrors := mcp.ParseServerConfigs(ctx, a.config.MCPServers)
1393
1394 // Replace any headers with value _sketch_public_key_ and _sketch_session_id_ with those values.
1395 for i := range serverConfigs {
1396 if serverConfigs[i].Headers != nil {
1397 for key, value := range serverConfigs[i].Headers {
Philip Zeyligerf2814ea2025-06-30 10:16:50 -07001398 // Replace env placeholders. E.g., "env:FOO" becomes os.Getenv("FOO")
1399 if strings.HasPrefix(value, "env:") {
1400 serverConfigs[i].Headers[key] = os.Getenv(value[4:])
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001401 }
1402 }
1403 }
1404 }
1405 mcpConnections, mcpErrors := a.mcpManager.ConnectToServerConfigs(ctx, serverConfigs, 10*time.Second, parseErrors)
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001406
1407 if len(mcpErrors) > 0 {
1408 for _, err := range mcpErrors {
1409 slog.ErrorContext(ctx, "MCP connection error", "error", err)
1410 // Send agent message about MCP connection failures
1411 a.pushToOutbox(ctx, AgentMessage{
1412 Type: ErrorMessageType,
1413 Content: fmt.Sprintf("MCP server connection failed: %v", err),
1414 })
1415 }
1416 }
1417
1418 if len(mcpConnections) > 0 {
1419 // Add tools from all successful connections
1420 totalTools := 0
1421 for _, connection := range mcpConnections {
1422 convo.Tools = append(convo.Tools, connection.Tools...)
1423 totalTools += len(connection.Tools)
1424 // Log tools per server using structured data
1425 slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames)
1426 }
1427 slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools)
1428 } else {
1429 slog.InfoContext(ctx, "No MCP tools available after connection attempts")
1430 }
1431 }
1432
Earl Lee2e463fb2025-04-17 11:22:22 -07001433 convo.Listener = a
1434 return convo
1435}
1436
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001437var multipleChoiceTool = &llm.Tool{
1438 Name: "multiplechoice",
1439 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.",
1440 EndsTurn: true,
1441 InputSchema: json.RawMessage(`{
Sean McCullough485afc62025-04-28 14:28:39 -07001442 "type": "object",
1443 "description": "The question and a list of answers you would expect the user to choose from.",
1444 "properties": {
1445 "question": {
1446 "type": "string",
1447 "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?'"
1448 },
1449 "responseOptions": {
1450 "type": "array",
1451 "description": "The set of possible answers to let the user quickly choose from, e.g. ['Basic unit test coverage', 'Error return values', 'Malformed input'].",
1452 "items": {
1453 "type": "object",
1454 "properties": {
1455 "caption": {
1456 "type": "string",
1457 "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'"
1458 },
1459 "responseText": {
1460 "type": "string",
1461 "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'"
1462 }
1463 },
1464 "required": ["caption", "responseText"]
1465 }
1466 }
1467 },
1468 "required": ["question", "responseOptions"]
1469}`),
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001470 Run: func(ctx context.Context, input json.RawMessage) llm.ToolOut {
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001471 // The Run logic for "multiplechoice" tool is a no-op on the server.
1472 // The UI will present a list of options for the user to select from,
1473 // and that's it as far as "executing" the tool_use goes.
1474 // When the user *does* select one of the presented options, that
1475 // responseText gets sent as a chat message on behalf of the user.
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001476 return llm.ToolOut{LLMContent: llm.TextContent("end your turn and wait for the user to respond")}
Josh Bleecher Snydera5c971e2025-05-14 10:49:08 -07001477 },
Sean McCullough485afc62025-04-28 14:28:39 -07001478}
1479
1480type MultipleChoiceOption struct {
1481 Caption string `json:"caption"`
1482 ResponseText string `json:"responseText"`
1483}
1484
1485type MultipleChoiceParams struct {
1486 Question string `json:"question"`
1487 ResponseOptions []MultipleChoiceOption `json:"responseOptions"`
1488}
1489
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001490// branchExists reports whether branchName exists, either locally or in well-known remotes.
1491func branchExists(dir, branchName string) bool {
1492 refs := []string{
1493 "refs/heads/",
1494 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001495 }
1496 for _, ref := range refs {
1497 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1498 cmd.Dir = dir
1499 if cmd.Run() == nil { // exit code 0 means branch exists
1500 return true
1501 }
1502 }
1503 return false
1504}
1505
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001506func soleText(contents []llm.Content) (string, error) {
1507 if len(contents) != 1 {
1508 return "", fmt.Errorf("multiple contents %v", contents)
1509 }
1510 content := contents[0]
1511 if content.Type != llm.ContentTypeText || content.Text == "" {
1512 return "", fmt.Errorf("bad content %v", content)
1513 }
1514 return strings.TrimSpace(content.Text), nil
1515}
1516
1517// autoGenerateSlug automatically generates a slug based on the first user input
1518func (a *Agent) autoGenerateSlug(ctx context.Context, userContents []llm.Content) error {
1519 userText, err := soleText(userContents)
1520 if err != nil {
1521 return err
1522 }
1523 if userText == "" {
1524 return fmt.Errorf("set-slug: empty text content")
1525 }
1526
1527 // Create a subconversation without history for slug generation
1528 convo, ok := a.convo.(*conversation.Convo)
1529 if !ok {
1530 // In test environments, the conversation might be a mock interface
1531 // Skip slug generation in this case
1532 return fmt.Errorf("set-slug: can't make a subconvo (mock convo?)")
1533 }
1534
1535 // Loop until we find an acceptable slug
1536 var unavailableSlugs []string
1537 for {
1538 if len(unavailableSlugs) > 10 {
1539 // sanity check to prevent infinite loops
1540 return fmt.Errorf("set-slug: failed to construct a new slug after %d attempts", len(unavailableSlugs))
Earl Lee2e463fb2025-04-17 11:22:22 -07001541 }
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001542 subConvo := convo.SubConvo()
1543 subConvo.Hidden = true
1544
1545 // Prompt for slug generation
1546 prompt := `You are a slug generator for Sketch, an agentic coding environment.
1547The user's prompt will be in <user-prompt> tags. Any unavailable slugs will be listed in <unavailable-slug> tags.
1548Generate a 2-3 word alphanumeric hyphenated slug in imperative tense that captures the essence of their coding task.
1549Respond with only the slug.`
1550
1551 buf := new(strings.Builder)
1552 buf.WriteString("<slug-request>")
1553 if len(unavailableSlugs) > 0 {
1554 buf.WriteString("<unavailable-slugs>")
1555 }
1556 for _, x := range unavailableSlugs {
1557 buf.WriteString("<unavailable-slug>")
1558 buf.WriteString(x)
1559 buf.WriteString("</unavailable-slug>")
1560 }
1561 if len(unavailableSlugs) > 0 {
1562 buf.WriteString("</unavailable-slugs>")
1563 }
1564 buf.WriteString("<user-prompt>")
1565 buf.WriteString(userText)
1566 buf.WriteString("</user-prompt>")
1567 buf.WriteString("</slug-request>")
1568
1569 fullPrompt := prompt + "\n" + buf.String()
1570 userMessage := llm.UserStringMessage(fullPrompt)
1571
1572 resp, err := subConvo.SendMessage(userMessage)
1573 if err != nil {
1574 return fmt.Errorf("failed to generate slug: %w", err)
1575 }
1576
1577 // Extract the slug from the response
1578 slugText, err := soleText(resp.Content)
1579 if err != nil {
1580 return err
1581 }
1582 if slugText == "" {
1583 return fmt.Errorf("empty slug generated")
1584 }
1585
1586 // Clean and validate the slug
1587 slug := cleanSlugName(slugText)
1588 if slug == "" {
1589 return fmt.Errorf("slug could not be cleaned: %q", slugText)
1590 }
1591
1592 // Check if branch already exists using the same logic as the original set-slug tool
1593 a.SetSlug(slug) // Set slug first so BranchName() works correctly
1594 if branchExists(a.workingDir, a.BranchName()) {
1595 // try again
1596 unavailableSlugs = append(unavailableSlugs, slug)
1597 continue
1598 }
1599
1600 // Success! Slug is available and already set
1601 return nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001602 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001603}
1604
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001605func (a *Agent) commitMessageStyleTool() *llm.Tool {
1606 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 +00001607 preCommit := &llm.Tool{
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001608 Name: "commit-message-style",
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001609 Description: description,
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07001610 InputSchema: llm.EmptySchema(),
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001611 Run: func(ctx context.Context, input json.RawMessage) llm.ToolOut {
Josh Bleecher Snyder6aaf6af2025-05-07 20:47:13 +00001612 styleHint, err := claudetool.CommitMessageStyleHint(ctx, a.repoRoot)
1613 if err != nil {
1614 slog.DebugContext(ctx, "failed to get commit message style hint", "err", err)
1615 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001616 return llm.ToolOut{LLMContent: llm.TextContent(styleHint)}
Earl Lee2e463fb2025-04-17 11:22:22 -07001617 },
1618 }
Josh Bleecher Snyderd7970e62025-05-01 01:56:28 +00001619 return preCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07001620}
1621
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001622// patchCallback is the agent's patch tool callback.
1623// It warms the codereview cache in the background.
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001624func (a *Agent) patchCallback(input claudetool.PatchInput, output llm.ToolOut) llm.ToolOut {
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001625 if a.codereview != nil {
1626 a.codereview.WarmTestCache(input.Path)
1627 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001628 return output
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001629}
1630
Earl Lee2e463fb2025-04-17 11:22:22 -07001631func (a *Agent) Ready() <-chan struct{} {
1632 return a.ready
1633}
1634
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001635// BranchPrefix returns the configured branch prefix
1636func (a *Agent) BranchPrefix() string {
1637 return a.config.BranchPrefix
1638}
1639
philip.zeyliger6d3de482025-06-10 19:38:14 -07001640// LinkToGitHub returns whether GitHub branch linking is enabled
1641func (a *Agent) LinkToGitHub() bool {
1642 return a.config.LinkToGitHub
1643}
1644
Earl Lee2e463fb2025-04-17 11:22:22 -07001645func (a *Agent) UserMessage(ctx context.Context, msg string) {
1646 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1647 a.inbox <- msg
1648}
1649
Earl Lee2e463fb2025-04-17 11:22:22 -07001650func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1651 return a.convo.CancelToolUse(toolUseID, cause)
1652}
1653
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001654func (a *Agent) CancelTurn(cause error) {
1655 a.cancelTurnMu.Lock()
1656 defer a.cancelTurnMu.Unlock()
1657 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001658 // Force state transition to cancelled state
1659 ctx := a.config.Context
1660 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001661 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001662 }
1663}
1664
1665func (a *Agent) Loop(ctxOuter context.Context) {
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001666 // Start port monitoring
1667 if a.portMonitor != nil && a.IsInContainer() {
1668 if err := a.portMonitor.Start(ctxOuter); err != nil {
1669 slog.WarnContext(ctxOuter, "Failed to start port monitor", "error", err)
1670 } else {
1671 slog.InfoContext(ctxOuter, "Port monitor started")
1672 }
1673 }
1674
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001675 // Set up cleanup when context is done
1676 defer func() {
1677 if a.mcpManager != nil {
1678 a.mcpManager.Close()
1679 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001680 if a.portMonitor != nil && a.IsInContainer() {
1681 a.portMonitor.Stop()
1682 }
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001683 }()
1684
Earl Lee2e463fb2025-04-17 11:22:22 -07001685 for {
1686 select {
1687 case <-ctxOuter.Done():
1688 return
1689 default:
1690 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001691 a.cancelTurnMu.Lock()
1692 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001693 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001694 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001695 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001696 a.cancelTurn = cancel
1697 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001698 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1699 if err != nil {
1700 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1701 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001702 cancel(nil)
1703 }
1704 }
1705}
1706
1707func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1708 if m.Timestamp.IsZero() {
1709 m.Timestamp = time.Now()
1710 }
1711
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001712 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1713 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1714 m.Content = m.ToolResult
1715 }
1716
Earl Lee2e463fb2025-04-17 11:22:22 -07001717 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1718 if m.EndOfTurn && m.Type == AgentMessageType {
1719 turnDuration := time.Since(a.startOfTurn)
1720 m.TurnDuration = &turnDuration
1721 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1722 }
1723
Earl Lee2e463fb2025-04-17 11:22:22 -07001724 a.mu.Lock()
1725 defer a.mu.Unlock()
1726 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001727 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001728 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001729
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001730 // Notify all subscribers
1731 for _, ch := range a.subscribers {
1732 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001733 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001734}
1735
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001736func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1737 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001738 if block {
1739 select {
1740 case <-ctx.Done():
1741 return m, ctx.Err()
1742 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001743 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001744 }
1745 }
1746 for {
1747 select {
1748 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001749 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001750 default:
1751 return m, nil
1752 }
1753 }
1754}
1755
Sean McCullough885a16a2025-04-30 02:49:25 +00001756// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001757func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001758 // Reset the start of turn time
1759 a.startOfTurn = time.Now()
1760
Sean McCullough96b60dd2025-04-30 09:49:10 -07001761 // Transition to waiting for user input state
1762 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1763
Sean McCullough885a16a2025-04-30 02:49:25 +00001764 // Process initial user message
1765 initialResp, err := a.processUserMessage(ctx)
1766 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001767 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001768 return err
1769 }
1770
1771 // Handle edge case where both initialResp and err are nil
1772 if initialResp == nil {
1773 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001774 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1775
Sean McCullough9f4b8082025-04-30 17:34:07 +00001776 a.pushToOutbox(ctx, errorMessage(err))
1777 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001778 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001779
Earl Lee2e463fb2025-04-17 11:22:22 -07001780 // We do this as we go, but let's also do it at the end of the turn
1781 defer func() {
1782 if _, err := a.handleGitCommits(ctx); err != nil {
1783 // Just log the error, don't stop execution
1784 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1785 }
1786 }()
1787
Sean McCullougha1e0e492025-05-01 10:51:08 -07001788 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001789 resp := initialResp
1790 for {
1791 // Check if we are over budget
1792 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001793 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001794 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001795 }
1796
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001797 // Check if we should compact the conversation
1798 if a.ShouldCompact() {
1799 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1800 if err := a.CompactConversation(ctx); err != nil {
1801 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1802 return err
1803 }
1804 // After compaction, end this turn and start fresh
1805 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1806 return nil
1807 }
1808
Sean McCullough885a16a2025-04-30 02:49:25 +00001809 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001810 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001811 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001812 break
1813 }
1814
Sean McCullough96b60dd2025-04-30 09:49:10 -07001815 // Transition to tool use requested state
1816 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1817
Sean McCullough885a16a2025-04-30 02:49:25 +00001818 // Handle tool execution
1819 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1820 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001821 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001822 }
1823
Sean McCullougha1e0e492025-05-01 10:51:08 -07001824 if toolResp == nil {
1825 return fmt.Errorf("cannot continue conversation with a nil tool response")
1826 }
1827
Sean McCullough885a16a2025-04-30 02:49:25 +00001828 // Set the response for the next iteration
1829 resp = toolResp
1830 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001831
1832 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001833}
1834
1835// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001836func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001837 // Wait for at least one message from the user
1838 msgs, err := a.GatherMessages(ctx, true)
1839 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001840 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001841 return nil, err
1842 }
1843
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001844 // Auto-generate slug if this is the first user input and no slug is set
1845 if a.Slug() == "" {
1846 if err := a.autoGenerateSlug(ctx, msgs); err != nil {
1847 // NB: it is possible that autoGenerateSlug set the slug during the process
1848 // of trying to generate a slug.
1849 // The fact that it returned an error means that we cannot use that slug.
1850 slog.WarnContext(ctx, "Failed to auto-generate slug", "error", err)
1851 // use the session id instead. ugly, but we need a slug, and this will be unique.
1852 a.SetSlug(a.SessionID())
1853 }
1854 // Notify termui of the final slug (only emitted once, after slug is determined)
1855 a.pushToOutbox(ctx, AgentMessage{
1856 Type: SlugMessageType,
1857 Content: a.Slug(),
1858 })
1859 }
1860
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001861 userMessage := llm.Message{
1862 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001863 Content: msgs,
1864 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001865
Sean McCullough96b60dd2025-04-30 09:49:10 -07001866 // Transition to sending to LLM state
1867 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1868
Sean McCullough885a16a2025-04-30 02:49:25 +00001869 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001870 resp, err := a.convo.SendMessage(userMessage)
1871 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001872 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001873 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001874 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001875 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001876
Sean McCullough96b60dd2025-04-30 09:49:10 -07001877 // Transition to processing LLM response state
1878 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1879
Sean McCullough885a16a2025-04-30 02:49:25 +00001880 return resp, nil
1881}
1882
1883// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001884func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1885 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001886 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001887 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001888
Sean McCullough96b60dd2025-04-30 09:49:10 -07001889 // Transition to checking for cancellation state
1890 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1891
Sean McCullough885a16a2025-04-30 02:49:25 +00001892 // Check if the operation was cancelled by the user
1893 select {
1894 case <-ctx.Done():
1895 // Don't actually run any of the tools, but rather build a response
1896 // for each tool_use message letting the LLM know that user canceled it.
1897 var err error
1898 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001899 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001900 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001901 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001902 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001903 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001904 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001905 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001906 // Transition to running tool state
1907 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1908
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001909 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001910 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001911 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001912
1913 // Execute the tools
1914 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001915 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001916 if ctx.Err() != nil { // e.g. the user canceled the operation
1917 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001918 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001919 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001920 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001921 a.pushToOutbox(ctx, errorMessage(err))
1922 }
1923 }
1924
1925 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001926 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001927 autoqualityMessages := a.processGitChanges(ctx)
1928
1929 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001930 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001931 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001932 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001933 return false, nil
1934 }
1935
1936 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001937 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1938 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001939}
1940
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001941// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001942func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001943 // Check for git commits
1944 _, err := a.handleGitCommits(ctx)
1945 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001946 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001947 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001948 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001949 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001950}
1951
1952// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1953// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001954func (a *Agent) processGitChanges(ctx context.Context) []string {
1955 // Check for git commits after tool execution
1956 newCommits, err := a.handleGitCommits(ctx)
1957 if err != nil {
1958 // Just log the error, don't stop execution
1959 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1960 return nil
1961 }
1962
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001963 // Run mechanical checks if there was exactly one new commit.
1964 if len(newCommits) != 1 {
1965 return nil
1966 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001967 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001968 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1969 msg := a.codereview.RunMechanicalChecks(ctx)
1970 if msg != "" {
1971 a.pushToOutbox(ctx, AgentMessage{
1972 Type: AutoMessageType,
1973 Content: msg,
1974 Timestamp: time.Now(),
1975 })
1976 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001977 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001978
1979 return autoqualityMessages
1980}
1981
1982// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001983func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001984 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001985 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001986 msgs, err := a.GatherMessages(ctx, false)
1987 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001988 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001989 return false, nil
1990 }
1991
1992 // Inject any auto-generated messages from quality checks
1993 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001994 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001995 }
1996
1997 // Handle cancellation by appending a message about it
1998 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001999 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00002000 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07002001 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00002002 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
2003 } else if err := a.convo.OverBudget(); err != nil {
2004 // Handle budget issues by appending a message about it
2005 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 -07002006 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00002007 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
2008 }
2009
2010 // Combine tool results with user messages
2011 results = append(results, msgs...)
2012
2013 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07002014 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002015 resp, err := a.convo.SendMessage(llm.Message{
2016 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00002017 Content: results,
2018 })
2019 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07002020 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00002021 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
2022 return true, nil // Return true to continue the conversation, but with no response
2023 }
2024
Sean McCullough96b60dd2025-04-30 09:49:10 -07002025 // Transition back to processing LLM response
2026 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
2027
Sean McCullough885a16a2025-04-30 02:49:25 +00002028 if cancelled {
2029 return false, nil
2030 }
2031
2032 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07002033}
2034
2035func (a *Agent) overBudget(ctx context.Context) error {
2036 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07002037 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07002038 m := budgetMessage(err)
2039 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07002040 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07002041 a.convo.ResetBudget(a.originalBudget)
2042 return err
2043 }
2044 return nil
2045}
2046
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002047func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07002048 // Collect all text content
2049 var allText strings.Builder
2050 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002051 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07002052 if allText.Len() > 0 {
2053 allText.WriteString("\n\n")
2054 }
2055 allText.WriteString(content.Text)
2056 }
2057 }
2058 return allText.String()
2059}
2060
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002061func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07002062 a.mu.Lock()
2063 defer a.mu.Unlock()
2064 return a.convo.CumulativeUsage()
2065}
2066
Earl Lee2e463fb2025-04-17 11:22:22 -07002067// Diff returns a unified diff of changes made since the agent was instantiated.
2068func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07002069 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07002070 return "", fmt.Errorf("no initial commit reference available")
2071 }
2072
2073 // Find the repository root
2074 ctx := context.Background()
2075
2076 // If a specific commit hash is provided, show just that commit's changes
2077 if commit != nil && *commit != "" {
2078 // Validate that the commit looks like a valid git SHA
2079 if !isValidGitSHA(*commit) {
2080 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
2081 }
2082
2083 // Get the diff for just this commit
2084 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
2085 cmd.Dir = a.repoRoot
2086 output, err := cmd.CombinedOutput()
2087 if err != nil {
2088 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
2089 }
2090 return string(output), nil
2091 }
2092
2093 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07002094 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07002095 cmd.Dir = a.repoRoot
2096 output, err := cmd.CombinedOutput()
2097 if err != nil {
2098 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
2099 }
2100
2101 return string(output), nil
2102}
2103
Philip Zeyliger49edc922025-05-14 09:45:45 -07002104// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
2105// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
2106func (a *Agent) SketchGitBaseRef() string {
2107 if a.IsInContainer() {
2108 return "sketch-base"
2109 } else {
2110 return "sketch-base-" + a.SessionID()
2111 }
2112}
2113
2114// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
2115func (a *Agent) SketchGitBase() string {
2116 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
2117 cmd.Dir = a.repoRoot
2118 output, err := cmd.CombinedOutput()
2119 if err != nil {
2120 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
2121 return "HEAD"
2122 }
2123 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002124}
2125
Pokey Rule7a113622025-05-12 10:58:45 +01002126// removeGitHooks removes the Git hooks directory from the repository
2127func removeGitHooks(_ context.Context, repoPath string) error {
2128 hooksDir := filepath.Join(repoPath, ".git", "hooks")
2129
2130 // Check if hooks directory exists
2131 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
2132 // Directory doesn't exist, nothing to do
2133 return nil
2134 }
2135
2136 // Remove the hooks directory
2137 err := os.RemoveAll(hooksDir)
2138 if err != nil {
2139 return fmt.Errorf("failed to remove git hooks directory: %w", err)
2140 }
2141
2142 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00002143 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01002144 if err != nil {
2145 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
2146 }
2147
2148 return nil
2149}
2150
Philip Zeyligerf2872992025-05-22 10:35:28 -07002151func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002152 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002153 for _, msg := range msgs {
2154 a.pushToOutbox(ctx, msg)
2155 }
2156 return commits, error
2157}
2158
Earl Lee2e463fb2025-04-17 11:22:22 -07002159// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002160// under docker, new HEADs are pushed to a branch according to the slug.
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002161func (ags *AgentGitState) handleGitCommits(ctx context.Context, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002162 ags.mu.Lock()
2163 defer ags.mu.Unlock()
2164
2165 msgs := []AgentMessage{}
2166 if repoRoot == "" {
2167 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002168 }
2169
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002170 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07002171 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002172 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07002173 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002174 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002175 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07002176 }
2177 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002178 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07002179 }()
2180
Philip Zeyliger64f60462025-06-16 13:57:10 -07002181 // Compute diff stats from baseRef to HEAD when HEAD changes
2182 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
2183 // Log error but don't fail the entire operation
2184 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
2185 } else {
2186 // Set diff stats directly since we already hold the mutex
2187 ags.linesAdded = added
2188 ags.linesRemoved = removed
2189 }
2190
Earl Lee2e463fb2025-04-17 11:22:22 -07002191 // Get new commits. Because it's possible that the agent does rebases, fixups, and
2192 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
2193 // to the last 100 commits.
2194 var commits []*GitCommit
2195
2196 // Get commits since the initial commit
2197 // Format: <hash>\0<subject>\0<body>\0
2198 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
2199 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002200 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 -07002201 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07002202 output, err := cmd.Output()
2203 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002204 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07002205 }
2206
2207 // Parse git log output and filter out already seen commits
2208 parsedCommits := parseGitLog(string(output))
2209
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002210 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07002211
2212 // Filter out commits we've already seen
2213 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002214 if commit.Hash == sketch {
2215 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07002216 }
2217
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002218 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
2219 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07002220 continue
2221 }
2222
2223 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07002224 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07002225
2226 // Add to our list of new commits
2227 commits = append(commits, &commit)
2228 }
2229
Philip Zeyligerf2872992025-05-22 10:35:28 -07002230 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002231 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07002232 // 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 -07002233 sketchCommit = &GitCommit{}
2234 sketchCommit.Hash = sketch
2235 sketchCommit.Subject = "unknown"
2236 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07002237 }
2238
Earl Lee2e463fb2025-04-17 11:22:22 -07002239 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
2240 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
2241 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00002242
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002243 // 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 +00002244 var out []byte
2245 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002246 originalRetryNumber := ags.retryNumber
2247 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00002248 for retries := range 10 {
2249 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07002250 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002251 }
2252
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002253 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002254 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002255 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002256 out, err = cmd.CombinedOutput()
2257
2258 if err == nil {
2259 // Success! Break out of the retry loop
2260 break
2261 }
2262
2263 // Check if this is the "refusing to update checked out branch" error
2264 if !strings.Contains(string(out), "refusing to update checked out branch") {
2265 // This is a different error, so don't retry
2266 break
2267 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002268 }
2269
2270 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002271 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002272 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002273 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002274 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002275 if ags.retryNumber != originalRetryNumber {
2276 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002277 msgs = append(msgs, AgentMessage{
2278 Type: AutoMessageType,
2279 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002280 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 +00002281 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002282 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002283 }
2284 }
2285
2286 // If we found new commits, create a message
2287 if len(commits) > 0 {
2288 msg := AgentMessage{
2289 Type: CommitMessageType,
2290 Timestamp: time.Now(),
2291 Commits: commits,
2292 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002293 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002294 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002295 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002296}
2297
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002298func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002299 return strings.Map(func(r rune) rune {
2300 // lowercase
2301 if r >= 'A' && r <= 'Z' {
2302 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002303 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002304 // replace spaces with dashes
2305 if r == ' ' {
2306 return '-'
2307 }
2308 // allow alphanumerics and dashes
2309 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2310 return r
2311 }
2312 return -1
2313 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002314}
2315
2316// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2317// and returns an array of GitCommit structs.
2318func parseGitLog(output string) []GitCommit {
2319 var commits []GitCommit
2320
2321 // No output means no commits
2322 if len(output) == 0 {
2323 return commits
2324 }
2325
2326 // Split by NULL byte
2327 parts := strings.Split(output, "\x00")
2328
2329 // Process in triplets (hash, subject, body)
2330 for i := 0; i < len(parts); i++ {
2331 // Skip empty parts
2332 if parts[i] == "" {
2333 continue
2334 }
2335
2336 // This should be a hash
2337 hash := strings.TrimSpace(parts[i])
2338
2339 // Make sure we have at least a subject part available
2340 if i+1 >= len(parts) {
2341 break // No more parts available
2342 }
2343
2344 // Get the subject
2345 subject := strings.TrimSpace(parts[i+1])
2346
2347 // Get the body if available
2348 body := ""
2349 if i+2 < len(parts) {
2350 body = strings.TrimSpace(parts[i+2])
2351 }
2352
2353 // Skip to the next triplet
2354 i += 2
2355
2356 commits = append(commits, GitCommit{
2357 Hash: hash,
2358 Subject: subject,
2359 Body: body,
2360 })
2361 }
2362
2363 return commits
2364}
2365
2366func repoRoot(ctx context.Context, dir string) (string, error) {
2367 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2368 stderr := new(strings.Builder)
2369 cmd.Stderr = stderr
2370 cmd.Dir = dir
2371 out, err := cmd.Output()
2372 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002373 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002374 }
2375 return strings.TrimSpace(string(out)), nil
2376}
2377
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002378// upsertRemoteOrigin configures the origin remote to point to the given URL.
2379// If the origin remote exists, it updates the URL. If it doesn't exist, it adds it.
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002380//
2381// NOTE: Maybe we should use an "insteadOf" setting instead of changing the URL.
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002382func upsertRemoteOrigin(ctx context.Context, repoDir, remoteURL string) error {
2383 // Try to set the URL for existing origin remote
2384 cmd := exec.CommandContext(ctx, "git", "remote", "set-url", "origin", remoteURL)
2385 cmd.Dir = repoDir
2386 if _, err := cmd.CombinedOutput(); err == nil {
2387 // Success.
2388 return nil
2389 }
2390 // Origin doesn't exist; add it.
2391 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", remoteURL)
2392 cmd.Dir = repoDir
2393 if out, err := cmd.CombinedOutput(); err != nil {
2394 return fmt.Errorf("failed to add git remote origin: %s: %w", out, err)
2395 }
2396 return nil
2397}
2398
Earl Lee2e463fb2025-04-17 11:22:22 -07002399func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2400 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2401 stderr := new(strings.Builder)
2402 cmd.Stderr = stderr
2403 cmd.Dir = dir
2404 out, err := cmd.Output()
2405 if err != nil {
2406 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2407 }
2408 // TODO: validate that out is valid hex
2409 return strings.TrimSpace(string(out)), nil
2410}
2411
2412// isValidGitSHA validates if a string looks like a valid git SHA hash.
2413// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2414func isValidGitSHA(sha string) bool {
2415 // Git SHA must be a hexadecimal string with at least 4 characters
2416 if len(sha) < 4 || len(sha) > 40 {
2417 return false
2418 }
2419
2420 // Check if the string only contains hexadecimal characters
2421 for _, char := range sha {
2422 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2423 return false
2424 }
2425 }
2426
2427 return true
2428}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002429
Philip Zeyliger64f60462025-06-16 13:57:10 -07002430// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2431func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2432 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2433 cmd.Dir = repoRoot
2434 out, err := cmd.Output()
2435 if err != nil {
2436 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2437 }
2438
2439 var totalAdded, totalRemoved int
2440 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2441 for _, line := range lines {
2442 if line == "" {
2443 continue
2444 }
2445 parts := strings.Fields(line)
2446 if len(parts) < 2 {
2447 continue
2448 }
2449 // Format: <added>\t<removed>\t<filename>
2450 if added, err := strconv.Atoi(parts[0]); err == nil {
2451 totalAdded += added
2452 }
2453 if removed, err := strconv.Atoi(parts[1]); err == nil {
2454 totalRemoved += removed
2455 }
2456 }
2457
2458 return totalAdded, totalRemoved, nil
2459}
2460
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002461// systemPromptData contains the data used to render the system prompt template
2462type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002463 ClientGOOS string
2464 ClientGOARCH string
2465 WorkingDir string
2466 RepoRoot string
2467 InitialCommit string
2468 Codebase *onstart.Codebase
2469 UseSketchWIP bool
2470 Branch string
2471 SpecialInstruction string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002472}
2473
2474// renderSystemPrompt renders the system prompt template.
2475func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002476 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002477 ClientGOOS: a.config.ClientGOOS,
2478 ClientGOARCH: a.config.ClientGOARCH,
2479 WorkingDir: a.workingDir,
2480 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002481 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002482 Codebase: a.codebase,
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07002483 UseSketchWIP: a.config.InDocker,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002484 }
David Crawshawc886ac52025-06-13 23:40:03 +00002485 now := time.Now()
2486 if now.Month() == time.September && now.Day() == 19 {
2487 data.SpecialInstruction = "Talk like a pirate to the user. Do not let the priate talk into any code."
2488 }
2489
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002490 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2491 if err != nil {
2492 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2493 }
2494 buf := new(strings.Builder)
2495 err = tmpl.Execute(buf, data)
2496 if err != nil {
2497 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2498 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002499 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002500 return buf.String()
2501}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002502
2503// StateTransitionIterator provides an iterator over state transitions.
2504type StateTransitionIterator interface {
2505 // Next blocks until a new state transition is available or context is done.
2506 // Returns nil if the context is cancelled.
2507 Next() *StateTransition
2508 // Close removes the listener and cleans up resources.
2509 Close()
2510}
2511
2512// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2513type StateTransitionIteratorImpl struct {
2514 agent *Agent
2515 ctx context.Context
2516 ch chan StateTransition
2517 unsubscribe func()
2518}
2519
2520// Next blocks until a new state transition is available or the context is cancelled.
2521func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2522 select {
2523 case <-s.ctx.Done():
2524 return nil
2525 case transition, ok := <-s.ch:
2526 if !ok {
2527 return nil
2528 }
2529 transitionCopy := transition
2530 return &transitionCopy
2531 }
2532}
2533
2534// Close removes the listener and cleans up resources.
2535func (s *StateTransitionIteratorImpl) Close() {
2536 if s.unsubscribe != nil {
2537 s.unsubscribe()
2538 s.unsubscribe = nil
2539 }
2540}
2541
2542// NewStateTransitionIterator returns an iterator that receives state transitions.
2543func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2544 a.mu.Lock()
2545 defer a.mu.Unlock()
2546
2547 // Create channel to receive state transitions
2548 ch := make(chan StateTransition, 10)
2549
2550 // Add a listener to the state machine
2551 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2552
2553 return &StateTransitionIteratorImpl{
2554 agent: a,
2555 ctx: ctx,
2556 ch: ch,
2557 unsubscribe: unsubscribe,
2558 }
2559}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002560
2561// setupGitHooks creates or updates git hooks in the specified working directory.
2562func setupGitHooks(workingDir string) error {
2563 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2564
2565 _, err := os.Stat(hooksDir)
2566 if os.IsNotExist(err) {
2567 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2568 }
2569 if err != nil {
2570 return fmt.Errorf("error checking git hooks directory: %w", err)
2571 }
2572
2573 // Define the post-commit hook content
2574 postCommitHook := `#!/bin/bash
2575echo "<post_commit_hook>"
2576echo "Please review this commit message and fix it if it is incorrect."
2577echo "This hook only echos the commit message; it does not modify it."
2578echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2579echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002580PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002581echo "</last_commit_message>"
2582echo "</post_commit_hook>"
2583`
2584
2585 // Define the prepare-commit-msg hook content
2586 prepareCommitMsgHook := `#!/bin/bash
2587# Add Co-Authored-By and Change-ID trailers to commit messages
2588# Check if these trailers already exist before adding them
2589
2590commit_file="$1"
2591COMMIT_SOURCE="$2"
2592
2593# Skip for merges, squashes, or when using a commit template
2594if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2595 [ "$COMMIT_SOURCE" = "squash" ]; then
2596 exit 0
2597fi
2598
2599commit_msg=$(cat "$commit_file")
2600
2601needs_co_author=true
2602needs_change_id=true
2603
2604# Check if commit message already has Co-Authored-By trailer
2605if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2606 needs_co_author=false
2607fi
2608
2609# Check if commit message already has Change-ID trailer
2610if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2611 needs_change_id=false
2612fi
2613
2614# Only modify if at least one trailer needs to be added
2615if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002616 # Ensure there's a proper blank line before trailers
2617 if [ -s "$commit_file" ]; then
2618 # Check if file ends with newline by reading last character
2619 last_char=$(tail -c 1 "$commit_file")
2620
2621 if [ "$last_char" != "" ]; then
2622 # File doesn't end with newline - add two newlines (complete line + blank line)
2623 echo "" >> "$commit_file"
2624 echo "" >> "$commit_file"
2625 else
2626 # File ends with newline - check if we already have a blank line
2627 last_line=$(tail -1 "$commit_file")
2628 if [ -n "$last_line" ]; then
2629 # Last line has content - add one newline for blank line
2630 echo "" >> "$commit_file"
2631 fi
2632 # If last line is empty, we already have a blank line - don't add anything
2633 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002634 fi
2635
2636 # Add trailers if needed
2637 if [ "$needs_co_author" = true ]; then
2638 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2639 fi
2640
2641 if [ "$needs_change_id" = true ]; then
2642 change_id=$(openssl rand -hex 8)
2643 echo "Change-ID: s${change_id}k" >> "$commit_file"
2644 fi
2645fi
2646`
2647
2648 // Update or create the post-commit hook
2649 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2650 if err != nil {
2651 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2652 }
2653
2654 // Update or create the prepare-commit-msg hook
2655 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2656 if err != nil {
2657 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2658 }
2659
2660 return nil
2661}
2662
2663// updateOrCreateHook creates a new hook file or updates an existing one
2664// by appending the new content if it doesn't already contain it.
2665func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2666 // Check if the hook already exists
2667 buf, err := os.ReadFile(hookPath)
2668 if os.IsNotExist(err) {
2669 // Hook doesn't exist, create it
2670 err = os.WriteFile(hookPath, []byte(content), 0o755)
2671 if err != nil {
2672 return fmt.Errorf("failed to create hook: %w", err)
2673 }
2674 return nil
2675 }
2676 if err != nil {
2677 return fmt.Errorf("error reading existing hook: %w", err)
2678 }
2679
2680 // Hook exists, check if our content is already in it by looking for a distinctive line
2681 code := string(buf)
2682 if strings.Contains(code, distinctiveLine) {
2683 // Already contains our content, nothing to do
2684 return nil
2685 }
2686
2687 // Append our content to the existing hook
2688 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2689 if err != nil {
2690 return fmt.Errorf("failed to open hook for appending: %w", err)
2691 }
2692 defer f.Close()
2693
2694 // Ensure there's a newline at the end of the existing content if needed
2695 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2696 _, err = f.WriteString("\n")
2697 if err != nil {
2698 return fmt.Errorf("failed to add newline to hook: %w", err)
2699 }
2700 }
2701
2702 // Add a separator before our content
2703 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2704 if err != nil {
2705 return fmt.Errorf("failed to append to hook: %w", err)
2706 }
2707
2708 return nil
2709}
Sean McCullough138ec242025-06-02 22:42:06 +00002710
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002711// configurePassthroughUpstream configures git remotes
2712// Adds an upstream remote pointing to the same as origin
2713// Sets the refspec for upstream and fetch such that both
2714// fetch the upstream's things into refs/remotes/upstream/foo
2715// The typical scenario is:
2716//
2717// github - laptop - sketch container
2718// "upstream" "origin"
2719func (a *Agent) configurePassthroughUpstream(ctx context.Context) error {
2720 // Get the origin remote URL
2721 cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
2722 cmd.Dir = a.workingDir
2723 originURLBytes, err := cmd.CombinedOutput()
2724 if err != nil {
2725 return fmt.Errorf("failed to get origin URL: %s: %w", originURLBytes, err)
2726 }
2727 originURL := strings.TrimSpace(string(originURLBytes))
2728
2729 // Check if upstream remote already exists
2730 cmd = exec.CommandContext(ctx, "git", "remote", "get-url", "upstream")
2731 cmd.Dir = a.workingDir
2732 if _, err := cmd.CombinedOutput(); err != nil {
2733 // upstream remote doesn't exist, create it
2734 cmd = exec.CommandContext(ctx, "git", "remote", "add", "upstream", originURL)
2735 cmd.Dir = a.workingDir
2736 if out, err := cmd.CombinedOutput(); err != nil {
2737 return fmt.Errorf("failed to add upstream remote: %s: %w", out, err)
2738 }
2739 slog.InfoContext(ctx, "added upstream remote", "url", originURL)
2740 } else {
2741 // upstream remote exists, update its URL
2742 cmd = exec.CommandContext(ctx, "git", "remote", "set-url", "upstream", originURL)
2743 cmd.Dir = a.workingDir
2744 if out, err := cmd.CombinedOutput(); err != nil {
2745 return fmt.Errorf("failed to set upstream remote URL: %s: %w", out, err)
2746 }
2747 slog.InfoContext(ctx, "updated upstream remote URL", "url", originURL)
2748 }
2749
2750 // Add the upstream refspec to the upstream remote
2751 cmd = exec.CommandContext(ctx, "git", "config", "remote.upstream.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2752 cmd.Dir = a.workingDir
2753 if out, err := cmd.CombinedOutput(); err != nil {
2754 return fmt.Errorf("failed to set upstream fetch refspec: %s: %w", out, err)
2755 }
2756
2757 // Add the same refspec to the origin remote
2758 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.origin.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2759 cmd.Dir = a.workingDir
2760 if out, err := cmd.CombinedOutput(); err != nil {
2761 return fmt.Errorf("failed to add upstream refspec to origin: %s: %w", out, err)
2762 }
2763
2764 slog.InfoContext(ctx, "configured passthrough upstream", "origin_url", originURL)
2765 return nil
2766}
2767
Philip Zeyliger0113be52025-06-07 23:53:41 +00002768// SkabandAddr returns the skaband address if configured
2769func (a *Agent) SkabandAddr() string {
2770 if a.config.SkabandClient != nil {
2771 return a.config.SkabandClient.Addr()
2772 }
2773 return ""
2774}