blob: 9e55491e91161c706c47ff084074d885d2f13e85 [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
Josh Bleecher Snyder3dd3e412025-07-22 20:32:03 -0700227 // Display contains content to be displayed to the user, set by tools
228 Display any `json:"display,omitempty"`
229
Earl Lee2e463fb2025-04-17 11:22:22 -0700230 Idx int `json:"idx"`
231}
232
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000233// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700234func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700235 if convo == nil {
236 m.ConversationID = ""
237 m.ParentConversationID = nil
238 return
239 }
240 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000241 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700242 if convo.Parent != nil {
243 m.ParentConversationID = &convo.Parent.ID
244 }
245}
246
Earl Lee2e463fb2025-04-17 11:22:22 -0700247// GitCommit represents a single git commit for a commit message
248type GitCommit struct {
249 Hash string `json:"hash"` // Full commit hash
250 Subject string `json:"subject"` // Commit subject line
251 Body string `json:"body"` // Full commit message body
252 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
253}
254
255// ToolCall represents a single tool call within an agent message
256type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700257 Name string `json:"name"`
258 Input string `json:"input"`
259 ToolCallId string `json:"tool_call_id"`
260 ResultMessage *AgentMessage `json:"result_message,omitempty"`
261 Args string `json:"args,omitempty"`
262 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700263}
264
265func (a *AgentMessage) Attr() slog.Attr {
266 var attrs []any = []any{
267 slog.String("type", string(a.Type)),
268 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700269 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700270 if a.EndOfTurn {
271 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
272 }
273 if a.Content != "" {
274 attrs = append(attrs, slog.String("content", a.Content))
275 }
276 if a.ToolName != "" {
277 attrs = append(attrs, slog.String("tool_name", a.ToolName))
278 }
279 if a.ToolInput != "" {
280 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
281 }
282 if a.Elapsed != nil {
283 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
284 }
285 if a.TurnDuration != nil {
286 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
287 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700288 if len(a.ToolResult) > 0 {
289 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700290 }
291 if a.ToolError {
292 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
293 }
294 if len(a.ToolCalls) > 0 {
295 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
296 for i, tc := range a.ToolCalls {
297 toolCallAttrs = append(toolCallAttrs, slog.Group(
298 fmt.Sprintf("tool_call_%d", i),
299 slog.String("name", tc.Name),
300 slog.String("input", tc.Input),
301 ))
302 }
303 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
304 }
305 if a.ConversationID != "" {
306 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
307 }
308 if a.ParentConversationID != nil {
309 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
310 }
311 if a.Usage != nil && !a.Usage.IsZero() {
312 attrs = append(attrs, a.Usage.Attr())
313 }
314 // TODO: timestamp, convo ids, idx?
315 return slog.Group("agent_message", attrs...)
316}
317
318func errorMessage(err error) AgentMessage {
319 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
320 if os.Getenv(("DEBUG")) == "1" {
321 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
322 }
323
324 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
325}
326
327func budgetMessage(err error) AgentMessage {
328 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
329}
330
331// ConvoInterface defines the interface for conversation interactions
332type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700333 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700334 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700335 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700336 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700337 SendMessage(message llm.Message) (*llm.Response, error)
338 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700339 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000340 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700341 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700342 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700343 SubConvoWithHistory() *conversation.Convo
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700344 DebugJSON() ([]byte, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700345}
346
Philip Zeyligerf2872992025-05-22 10:35:28 -0700347// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700348// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700349// any time we notice we need to.
350type AgentGitState struct {
351 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700352 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700353 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000354 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700355 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700356 slug string // Human-readable session identifier
357 retryNumber int // Number to append when branch conflicts occur
Philip Zeyliger64f60462025-06-16 13:57:10 -0700358 linesAdded int // Lines added from sketch-base to HEAD
359 linesRemoved int // Lines removed from sketch-base to HEAD
Philip Zeyligerf2872992025-05-22 10:35:28 -0700360}
361
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700362func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700363 ags.mu.Lock()
364 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700365 if ags.slug != slug {
366 ags.retryNumber = 0
367 }
368 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700369}
370
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700371func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700372 ags.mu.Lock()
373 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700374 return ags.slug
375}
376
377func (ags *AgentGitState) IncrementRetryNumber() {
378 ags.mu.Lock()
379 defer ags.mu.Unlock()
380 ags.retryNumber++
381}
382
Philip Zeyliger64f60462025-06-16 13:57:10 -0700383func (ags *AgentGitState) DiffStats() (int, int) {
384 ags.mu.Lock()
385 defer ags.mu.Unlock()
386 return ags.linesAdded, ags.linesRemoved
387}
388
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700389// HasSeenCommits returns true if any commits have been processed
390func (ags *AgentGitState) HasSeenCommits() bool {
391 ags.mu.Lock()
392 defer ags.mu.Unlock()
393 return len(ags.seenCommits) > 0
394}
395
396func (ags *AgentGitState) RetryNumber() int {
397 ags.mu.Lock()
398 defer ags.mu.Unlock()
399 return ags.retryNumber
400}
401
402func (ags *AgentGitState) BranchName(prefix string) string {
403 ags.mu.Lock()
404 defer ags.mu.Unlock()
405 return ags.branchNameLocked(prefix)
406}
407
408func (ags *AgentGitState) branchNameLocked(prefix string) string {
409 if ags.slug == "" {
410 return ""
411 }
412 if ags.retryNumber == 0 {
413 return prefix + ags.slug
414 }
415 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700416}
417
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000418func (ags *AgentGitState) Upstream() string {
419 ags.mu.Lock()
420 defer ags.mu.Unlock()
421 return ags.upstream
422}
423
Earl Lee2e463fb2025-04-17 11:22:22 -0700424type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700425 convo ConvoInterface
426 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700427 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700428 workingDir string
429 repoRoot string // workingDir may be a subdir of repoRoot
430 url string
431 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000432 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700433 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000434 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700435 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700436 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000437 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700438 // State machine to track agent state
439 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000440 // Outside information
441 outsideHostname string
442 outsideOS string
443 outsideWorkingDir string
Philip Zeyliger194bfa82025-06-24 06:03:06 -0700444 // MCP manager for handling MCP server connections
445 mcpManager *mcp.MCPManager
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000446 // Port monitor for tracking TCP ports
447 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700448
449 // Time when the current turn started (reset at the beginning of InnerLoop)
450 startOfTurn time.Time
451
452 // Inbox - for messages from the user to the agent.
453 // sent on by UserMessage
454 // . e.g. when user types into the chat textarea
455 // read from by GatherMessages
456 inbox chan string
457
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000458 // protects cancelTurn
459 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700460 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000461 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700462
463 // protects following
464 mu sync.Mutex
465
466 // Stores all messages for this agent
467 history []AgentMessage
468
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700469 // Iterators add themselves here when they're ready to be notified of new messages.
470 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700471
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000472 // Track outstanding LLM call IDs
473 outstandingLLMCalls map[string]struct{}
474
475 // Track outstanding tool calls by ID with their names
476 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700477}
478
banksean5ab8fb82025-07-09 12:34:55 -0700479// TokenContextWindow implements CodingAgent.
480func (a *Agent) TokenContextWindow() int {
481 return a.config.Service.TokenContextWindow()
482}
483
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700484// GetConvo returns the conversation interface for debugging purposes.
485func (a *Agent) GetConvo() ConvoInterface {
486 return a.convo
487}
488
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700489// NewIterator implements CodingAgent.
490func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
491 a.mu.Lock()
492 defer a.mu.Unlock()
493
494 return &MessageIteratorImpl{
495 agent: a,
496 ctx: ctx,
497 nextMessageIdx: nextMessageIdx,
498 ch: make(chan *AgentMessage, 100),
499 }
500}
501
502type MessageIteratorImpl struct {
503 agent *Agent
504 ctx context.Context
505 nextMessageIdx int
506 ch chan *AgentMessage
507 subscribed bool
508}
509
510func (m *MessageIteratorImpl) Close() {
511 m.agent.mu.Lock()
512 defer m.agent.mu.Unlock()
513 // Delete ourselves from the subscribers list
514 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
515 return x == m.ch
516 })
517 close(m.ch)
518}
519
520func (m *MessageIteratorImpl) Next() *AgentMessage {
521 // We avoid subscription at creation to let ourselves catch up to "current state"
522 // before subscribing.
523 if !m.subscribed {
524 m.agent.mu.Lock()
525 if m.nextMessageIdx < len(m.agent.history) {
526 msg := &m.agent.history[m.nextMessageIdx]
527 m.nextMessageIdx++
528 m.agent.mu.Unlock()
529 return msg
530 }
531 // The next message doesn't exist yet, so let's subscribe
532 m.agent.subscribers = append(m.agent.subscribers, m.ch)
533 m.subscribed = true
534 m.agent.mu.Unlock()
535 }
536
537 for {
538 select {
539 case <-m.ctx.Done():
540 m.agent.mu.Lock()
541 // Delete ourselves from the subscribers list
542 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
543 return x == m.ch
544 })
545 m.subscribed = false
546 m.agent.mu.Unlock()
547 return nil
548 case msg, ok := <-m.ch:
549 if !ok {
550 // Close may have been called
551 return nil
552 }
553 if msg.Idx == m.nextMessageIdx {
554 m.nextMessageIdx++
555 return msg
556 }
557 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
558 panic("out of order message")
559 }
560 }
561}
562
Sean McCulloughd9d45812025-04-30 16:53:41 -0700563// Assert that Agent satisfies the CodingAgent interface.
564var _ CodingAgent = &Agent{}
565
566// StateName implements CodingAgent.
567func (a *Agent) CurrentStateName() string {
568 if a.stateMachine == nil {
569 return ""
570 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000571 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700572}
573
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700574// CurrentTodoContent returns the current todo list data as JSON.
575// It returns an empty string if no todos exist.
576func (a *Agent) CurrentTodoContent() string {
577 todoPath := claudetool.TodoFilePath(a.config.SessionID)
578 content, err := os.ReadFile(todoPath)
579 if err != nil {
580 return ""
581 }
582 return string(content)
583}
584
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700585// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
586func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
587 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.
588
589IMPORTANT: 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.
590
591Please create a detailed summary that includes:
592
5931. **User's Request**: What did the user originally ask me to do? What was their goal?
594
5952. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
596
5973. **Key Technical Decisions**: What important technical choices were made during our work and why?
598
5994. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
600
6015. **Next Steps**: What still needs to be done to complete the user's request?
602
6036. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
604
605Focus 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.
606
607Reply with ONLY the summary content - no meta-commentary about creating the summary.`
608
609 userMessage := llm.UserStringMessage(msg)
610 // Use a subconversation with history to get the summary
611 // TODO: We don't have any tools here, so we should have enough tokens
612 // to capture a summary, but we may need to modify the history (e.g., remove
613 // TODO data) to save on some tokens.
614 convo := a.convo.SubConvoWithHistory()
615
616 // Modify the system prompt to provide context about the original task
617 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000618 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 -0700619
620Your 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.
621
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000622Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700623
624 resp, err := convo.SendMessage(userMessage)
625 if err != nil {
626 a.pushToOutbox(ctx, errorMessage(err))
627 return "", err
628 }
629 textContent := collectTextContent(resp)
630
631 // Restore original system prompt (though this subconvo will be discarded)
632 convo.SystemPrompt = originalSystemPrompt
633
634 return textContent, nil
635}
636
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000637// dumpMessageHistoryToTmp dumps the agent's entire message history to /tmp as JSON
638// and returns the filename
639func (a *Agent) dumpMessageHistoryToTmp(ctx context.Context) (string, error) {
640 // Create a filename based on session ID and timestamp
641 timestamp := time.Now().Format("20060102-150405")
642 filename := fmt.Sprintf("/tmp/sketch-messages-%s-%s.json", a.config.SessionID, timestamp)
643
644 // Marshal the entire message history to JSON
645 jsonData, err := json.MarshalIndent(a.history, "", " ")
646 if err != nil {
647 return "", fmt.Errorf("failed to marshal message history: %w", err)
648 }
649
650 // Write to file
Autoformatter3ad8c8d2025-07-15 21:05:23 +0000651 if err := os.WriteFile(filename, jsonData, 0o644); err != nil {
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000652 return "", fmt.Errorf("failed to write message history to %s: %w", filename, err)
653 }
654
655 slog.InfoContext(ctx, "Dumped message history to file", "filename", filename, "message_count", len(a.history))
656 return filename, nil
657}
658
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700659// CompactConversation compacts the current conversation by generating a summary
660// and restarting the conversation with that summary as the initial context
661func (a *Agent) CompactConversation(ctx context.Context) error {
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000662 // Dump the entire message history to /tmp as JSON before compacting
663 dumpFile, err := a.dumpMessageHistoryToTmp(ctx)
664 if err != nil {
665 slog.WarnContext(ctx, "Failed to dump message history to /tmp", "error", err)
666 // Continue with compaction even if dump fails
667 }
668
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700669 summary, err := a.generateConversationSummary(ctx)
670 if err != nil {
671 return fmt.Errorf("failed to generate conversation summary: %w", err)
672 }
673
674 a.mu.Lock()
675
676 // Get usage information before resetting conversation
677 lastUsage := a.convo.LastUsage()
678 contextWindow := a.config.Service.TokenContextWindow()
679 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
680
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000681 // Preserve cumulative usage across compaction
682 cumulativeUsage := a.convo.CumulativeUsage()
683
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700684 // Reset conversation state but keep all other state (git, working dir, etc.)
685 a.firstMessageIndex = len(a.history)
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000686 a.convo = a.initConvoWithUsage(&cumulativeUsage)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700687
688 a.mu.Unlock()
689
690 // Create informative compaction message with token details
691 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
692 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
693 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
694
695 a.pushToOutbox(ctx, AgentMessage{
696 Type: CompactMessageType,
697 Content: compactionMsg,
698 })
699
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000700 // Create the message content with dump file information if available
701 var messageContent string
702 if dumpFile != "" {
703 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)
704 } else {
705 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)
706 }
707
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700708 a.pushToOutbox(ctx, AgentMessage{
709 Type: UserMessageType,
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000710 Content: messageContent,
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700711 })
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000712 a.inbox <- messageContent
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700713
714 return nil
715}
716
Earl Lee2e463fb2025-04-17 11:22:22 -0700717func (a *Agent) URL() string { return a.url }
718
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000719// GetPorts returns the cached list of open TCP ports.
720func (a *Agent) GetPorts() []portlist.Port {
721 if a.portMonitor == nil {
722 return nil
723 }
724 return a.portMonitor.GetPorts()
725}
726
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000727// BranchName returns the git branch name for the conversation.
728func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700729 return a.gitState.BranchName(a.config.BranchPrefix)
730}
731
732// Slug returns the slug identifier for this conversation.
733func (a *Agent) Slug() string {
734 return a.gitState.Slug()
735}
736
737// IncrementRetryNumber increments the retry number for branch naming conflicts
738func (a *Agent) IncrementRetryNumber() {
739 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000740}
741
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000742// OutstandingLLMCallCount returns the number of outstanding LLM calls.
743func (a *Agent) OutstandingLLMCallCount() int {
744 a.mu.Lock()
745 defer a.mu.Unlock()
746 return len(a.outstandingLLMCalls)
747}
748
749// OutstandingToolCalls returns the names of outstanding tool calls.
750func (a *Agent) OutstandingToolCalls() []string {
751 a.mu.Lock()
752 defer a.mu.Unlock()
753
754 tools := make([]string, 0, len(a.outstandingToolCalls))
755 for _, toolName := range a.outstandingToolCalls {
756 tools = append(tools, toolName)
757 }
758 return tools
759}
760
Earl Lee2e463fb2025-04-17 11:22:22 -0700761// OS returns the operating system of the client.
762func (a *Agent) OS() string {
763 return a.config.ClientGOOS
764}
765
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000766func (a *Agent) SessionID() string {
767 return a.config.SessionID
768}
769
philip.zeyliger8773e682025-06-11 21:36:21 -0700770// SSHConnectionString returns the SSH connection string for the container.
771func (a *Agent) SSHConnectionString() string {
772 return a.config.SSHConnectionString
773}
774
Philip Zeyliger18532b22025-04-23 21:11:46 +0000775// OutsideOS returns the operating system of the outside system.
776func (a *Agent) OutsideOS() string {
777 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000778}
779
Philip Zeyliger18532b22025-04-23 21:11:46 +0000780// OutsideHostname returns the hostname of the outside system.
781func (a *Agent) OutsideHostname() string {
782 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000783}
784
Philip Zeyliger18532b22025-04-23 21:11:46 +0000785// OutsideWorkingDir returns the working directory on the outside system.
786func (a *Agent) OutsideWorkingDir() string {
787 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000788}
789
790// GitOrigin returns the URL of the git remote 'origin' if it exists.
791func (a *Agent) GitOrigin() string {
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +0000792 return a.config.OriginalGitOrigin
Philip Zeyligerd1402952025-04-23 03:54:37 +0000793}
794
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700795// PassthroughUpstream returns whether passthrough upstream is enabled.
796func (a *Agent) PassthroughUpstream() bool {
797 return a.config.PassthroughUpstream
798}
799
bankseancad67b02025-06-27 21:57:05 +0000800// GitUsername returns the git user name from the agent config.
801func (a *Agent) GitUsername() string {
802 return a.config.GitUsername
803}
804
Philip Zeyliger64f60462025-06-16 13:57:10 -0700805// DiffStats returns the number of lines added and removed from sketch-base to HEAD
806func (a *Agent) DiffStats() (int, int) {
807 return a.gitState.DiffStats()
808}
809
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000810func (a *Agent) OpenBrowser(url string) {
811 if !a.IsInContainer() {
812 browser.Open(url)
813 return
814 }
815 // We're in Docker, need to send a request to the Git server
816 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700817 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000818 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700819 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000820 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700821 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000822 return
823 }
824 defer resp.Body.Close()
825 if resp.StatusCode == http.StatusOK {
826 return
827 }
828 body, _ := io.ReadAll(resp.Body)
829 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
830}
831
Sean McCullough96b60dd2025-04-30 09:49:10 -0700832// CurrentState returns the current state of the agent's state machine.
833func (a *Agent) CurrentState() State {
834 return a.stateMachine.CurrentState()
835}
836
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700837func (a *Agent) IsInContainer() bool {
838 return a.config.InDocker
839}
840
841func (a *Agent) FirstMessageIndex() int {
842 a.mu.Lock()
843 defer a.mu.Unlock()
844 return a.firstMessageIndex
845}
846
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700847// SetSlug sets a human-readable identifier for the conversation.
848func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700849 a.mu.Lock()
850 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700851
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700852 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000853 convo, ok := a.convo.(*conversation.Convo)
854 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700855 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000856 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700857}
858
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000859// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700860func (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 +0000861 // Track the tool call
862 a.mu.Lock()
863 a.outstandingToolCalls[id] = toolName
864 a.mu.Unlock()
865}
866
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700867// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
868// If there's only one element in the array and it's a text type, it returns that text directly.
869// It also processes nested ToolResult arrays recursively.
870func contentToString(contents []llm.Content) string {
871 if len(contents) == 0 {
872 return ""
873 }
874
875 // If there's only one element and it's a text type, return it directly
876 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
877 return contents[0].Text
878 }
879
880 // Otherwise, concatenate all text content
881 var result strings.Builder
882 for _, content := range contents {
883 if content.Type == llm.ContentTypeText {
884 result.WriteString(content.Text)
885 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
886 // Recursively process nested tool results
887 result.WriteString(contentToString(content.ToolResult))
888 }
889 }
890
891 return result.String()
892}
893
Earl Lee2e463fb2025-04-17 11:22:22 -0700894// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700895func (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 +0000896 // Remove the tool call from outstanding calls
897 a.mu.Lock()
898 delete(a.outstandingToolCalls, toolID)
899 a.mu.Unlock()
900
Earl Lee2e463fb2025-04-17 11:22:22 -0700901 m := AgentMessage{
902 Type: ToolUseMessageType,
903 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700904 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700905 ToolError: content.ToolError,
906 ToolName: toolName,
907 ToolInput: string(toolInput),
908 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700909 StartTime: content.ToolUseStartTime,
910 EndTime: content.ToolUseEndTime,
Josh Bleecher Snyder3dd3e412025-07-22 20:32:03 -0700911 Display: content.Display,
Earl Lee2e463fb2025-04-17 11:22:22 -0700912 }
913
914 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700915 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
916 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700917 m.Elapsed = &elapsed
918 }
919
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700920 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700921 a.pushToOutbox(ctx, m)
922}
923
924// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700925func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000926 a.mu.Lock()
927 defer a.mu.Unlock()
928 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700929 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
930}
931
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700932// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700933// that need to be displayed (as well as tool calls that we send along when
934// they're done). (It would be reasonable to also mention tool calls when they're
935// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700936func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000937 // Remove the LLM call from outstanding calls
938 a.mu.Lock()
939 delete(a.outstandingLLMCalls, id)
940 a.mu.Unlock()
941
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700942 if resp == nil {
943 // LLM API call failed
944 m := AgentMessage{
945 Type: ErrorMessageType,
946 Content: "API call failed, type 'continue' to try again",
947 }
948 m.SetConvo(convo)
949 a.pushToOutbox(ctx, m)
950 return
951 }
952
Earl Lee2e463fb2025-04-17 11:22:22 -0700953 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700954 if convo.Parent == nil { // subconvos never end the turn
955 switch resp.StopReason {
956 case llm.StopReasonToolUse:
957 // Check whether any of the tool calls are for tools that should end the turn
958 ToolSearch:
959 for _, part := range resp.Content {
960 if part.Type != llm.ContentTypeToolUse {
961 continue
962 }
Sean McCullough021557a2025-05-05 23:20:53 +0000963 // Find the tool by name
964 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700965 if tool.Name == part.ToolName {
966 endOfTurn = tool.EndsTurn
967 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000968 }
969 }
Sean McCullough021557a2025-05-05 23:20:53 +0000970 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700971 default:
972 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000973 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700974 }
975 m := AgentMessage{
976 Type: AgentMessageType,
977 Content: collectTextContent(resp),
978 EndOfTurn: endOfTurn,
979 Usage: &resp.Usage,
980 StartTime: resp.StartTime,
981 EndTime: resp.EndTime,
982 }
983
984 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700985 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700986 var toolCalls []ToolCall
987 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700988 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700989 toolCalls = append(toolCalls, ToolCall{
990 Name: part.ToolName,
991 Input: string(part.ToolInput),
992 ToolCallId: part.ID,
993 })
994 }
995 }
996 m.ToolCalls = toolCalls
997 }
998
999 // Calculate the elapsed time if both start and end times are set
1000 if resp.StartTime != nil && resp.EndTime != nil {
1001 elapsed := resp.EndTime.Sub(*resp.StartTime)
1002 m.Elapsed = &elapsed
1003 }
1004
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -07001005 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -07001006 a.pushToOutbox(ctx, m)
1007}
1008
1009// WorkingDir implements CodingAgent.
1010func (a *Agent) WorkingDir() string {
1011 return a.workingDir
1012}
1013
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001014// RepoRoot returns the git repository root directory.
1015func (a *Agent) RepoRoot() string {
1016 return a.repoRoot
1017}
1018
Earl Lee2e463fb2025-04-17 11:22:22 -07001019// MessageCount implements CodingAgent.
1020func (a *Agent) MessageCount() int {
1021 a.mu.Lock()
1022 defer a.mu.Unlock()
1023 return len(a.history)
1024}
1025
1026// Messages implements CodingAgent.
1027func (a *Agent) Messages(start int, end int) []AgentMessage {
1028 a.mu.Lock()
1029 defer a.mu.Unlock()
1030 return slices.Clone(a.history[start:end])
1031}
1032
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001033// ShouldCompact checks if the conversation should be compacted based on token usage
1034func (a *Agent) ShouldCompact() bool {
1035 // Get the threshold from environment variable, default to 0.94 (94%)
1036 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
1037 // and a little bit of buffer.)
1038 thresholdRatio := 0.94
1039 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
1040 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
1041 thresholdRatio = parsed
1042 }
1043 }
1044
1045 // Get the most recent usage to check current context size
1046 lastUsage := a.convo.LastUsage()
1047
1048 if lastUsage.InputTokens == 0 {
1049 // No API calls made yet
1050 return false
1051 }
1052
1053 // Calculate the current context size from the last API call
1054 // This includes all tokens that were part of the input context:
1055 // - Input tokens (user messages, system prompt, conversation history)
1056 // - Cache read tokens (cached parts of the context)
1057 // - Cache creation tokens (new parts being cached)
1058 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
1059
1060 // Get the service's token context window
1061 service := a.config.Service
1062 contextWindow := service.TokenContextWindow()
1063
1064 // Calculate threshold
1065 threshold := uint64(float64(contextWindow) * thresholdRatio)
1066
1067 // Check if we've exceeded the threshold
1068 return currentContextSize >= threshold
1069}
1070
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001071func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -07001072 return a.originalBudget
1073}
1074
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001075// Upstream returns the upstream branch for git work
1076func (a *Agent) Upstream() string {
1077 return a.gitState.Upstream()
1078}
1079
Earl Lee2e463fb2025-04-17 11:22:22 -07001080// AgentConfig contains configuration for creating a new Agent.
1081type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001082 Context context.Context
1083 Service llm.Service
1084 Budget conversation.Budget
1085 GitUsername string
1086 GitEmail string
1087 SessionID string
1088 ClientGOOS string
1089 ClientGOARCH string
1090 InDocker bool
1091 OneShot bool
1092 WorkingDir string
Philip Zeyliger18532b22025-04-23 21:11:46 +00001093 // Outside information
1094 OutsideHostname string
1095 OutsideOS string
1096 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001097
1098 // Outtie's HTTP to, e.g., open a browser
1099 OutsideHTTP string
1100 // Outtie's Git server
1101 GitRemoteAddr string
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001102 // Original git origin URL from host repository, if any
1103 OriginalGitOrigin string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001104 // Upstream branch for git work
1105 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001106 // Commit to checkout from Outtie
1107 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001108 // Prefix for git branches created by sketch
1109 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001110 // LinkToGitHub enables GitHub branch linking in UI
1111 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001112 // SSH connection string for connecting to the container
1113 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001114 // Skaband client for session history (optional)
1115 SkabandClient *skabandclient.SkabandClient
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001116 // MCP server configurations
1117 MCPServers []string
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001118 // Timeout configuration for bash tool
1119 BashTimeouts *claudetool.Timeouts
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001120 // PassthroughUpstream configures upstream remote for passthrough to innie
1121 PassthroughUpstream bool
Earl Lee2e463fb2025-04-17 11:22:22 -07001122}
1123
1124// NewAgent creates a new Agent.
1125// It is not usable until Init() is called.
1126func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001127 // Set default branch prefix if not specified
1128 if config.BranchPrefix == "" {
1129 config.BranchPrefix = "sketch/"
1130 }
1131
Earl Lee2e463fb2025-04-17 11:22:22 -07001132 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001133 config: config,
1134 ready: make(chan struct{}),
1135 inbox: make(chan string, 100),
1136 subscribers: make([]chan *AgentMessage, 0),
1137 startedAt: time.Now(),
1138 originalBudget: config.Budget,
1139 gitState: AgentGitState{
1140 seenCommits: make(map[string]bool),
1141 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001142 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001143 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001144 outsideHostname: config.OutsideHostname,
1145 outsideOS: config.OutsideOS,
1146 outsideWorkingDir: config.OutsideWorkingDir,
1147 outstandingLLMCalls: make(map[string]struct{}),
1148 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001149 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001150 workingDir: config.WorkingDir,
1151 outsideHTTP: config.OutsideHTTP,
Philip Zeyligerda623b52025-07-04 01:12:38 +00001152
1153 mcpManager: mcp.NewMCPManager(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001154 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001155
1156 // Initialize port monitor with 5-second interval
1157 agent.portMonitor = NewPortMonitor(agent, 5*time.Second)
1158
Earl Lee2e463fb2025-04-17 11:22:22 -07001159 return agent
1160}
1161
1162type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001163 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001164
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001165 InDocker bool
1166 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001167}
1168
1169func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001170 if a.convo != nil {
1171 return fmt.Errorf("Agent.Init: already initialized")
1172 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001173 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001174 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001175
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001176 // If a remote + commit was specified, clone it.
1177 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001178 if _, err := os.Stat("/app/.git"); err != nil {
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00001179 slog.InfoContext(ctx, "cloning git repo", "commit", a.config.Commit)
1180 // TODO: --reference-if-able instead?
1181 cmd := exec.CommandContext(ctx, "git", "clone", "--reference", "/git-ref", a.gitState.gitRemoteAddr, "/app")
1182 if out, err := cmd.CombinedOutput(); err != nil {
1183 return fmt.Errorf("failed to clone repository from %s: %s: %w", a.gitState.gitRemoteAddr, out, err)
1184 }
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001185 }
1186 }
1187
1188 if a.workingDir != "" {
1189 err := os.Chdir(a.workingDir)
1190 if err != nil {
1191 return fmt.Errorf("failed to change working directory to %s: %w", a.workingDir, err)
1192 }
1193 }
1194
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001195 if !ini.NoGit {
Philip Zeyligeraccf37c2025-07-18 07:29:19 -07001196 if a.gitState.gitRemoteAddr != "" {
1197 if err := upsertRemoteOrigin(ctx, "/app", a.gitState.gitRemoteAddr); err != nil {
1198 return err
1199 }
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001200 }
Philip Zeyligere1c8b7b2025-07-03 14:50:26 -07001201
1202 // Configure git user settings
1203 if a.config.GitEmail != "" {
1204 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.email", a.config.GitEmail)
1205 cmd.Dir = a.workingDir
1206 if out, err := cmd.CombinedOutput(); err != nil {
1207 return fmt.Errorf("git config --global user.email: %s: %v", out, err)
1208 }
1209 }
1210 if a.config.GitUsername != "" {
1211 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.name", a.config.GitUsername)
1212 cmd.Dir = a.workingDir
1213 if out, err := cmd.CombinedOutput(); err != nil {
1214 return fmt.Errorf("git config --global user.name: %s: %v", out, err)
1215 }
1216 }
1217 // Configure git http.postBuffer
1218 cmd := exec.CommandContext(ctx, "git", "config", "--global", "http.postBuffer", "524288000")
1219 cmd.Dir = a.workingDir
1220 if out, err := cmd.CombinedOutput(); err != nil {
1221 return fmt.Errorf("git config --global http.postBuffer: %s: %v", out, err)
1222 }
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001223
1224 // Configure passthrough upstream if enabled
1225 if a.config.PassthroughUpstream {
1226 if err := a.configurePassthroughUpstream(ctx); err != nil {
1227 return fmt.Errorf("failed to configure passthrough upstream: %w", err)
1228 }
1229 }
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001230 }
1231
Philip Zeyligerf2872992025-05-22 10:35:28 -07001232 // If a commit was specified, we fetch and reset to it.
1233 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001234 slog.InfoContext(ctx, "updating git repo", "commit", a.config.Commit)
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001235
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001236 cmd := exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001237 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001238 if out, err := cmd.CombinedOutput(); err != nil {
1239 return fmt.Errorf("git fetch: %s: %w", out, err)
1240 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001241 // The -B resets the branch if it already exists (or creates it if it doesn't)
1242 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001243 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001244 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1245 // Remove git hooks if they exist and retry
1246 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001247 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001248 if _, statErr := os.Stat(hookPath); statErr == nil {
1249 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1250 slog.String("error", err.Error()),
1251 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001252 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001253 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1254 }
1255
1256 // Retry the checkout operation
Philip Zeyliger1417b692025-06-12 11:07:04 -07001257 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001258 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001259 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001260 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 +01001261 }
1262 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001263 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001264 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001265 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001266 } else if a.IsInContainer() {
1267 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1268 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1269 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1270 cmd.Dir = a.workingDir
1271 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1272 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1273 }
1274 } else {
1275 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001276 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001277
1278 if ini.HostAddr != "" {
1279 a.url = "http://" + ini.HostAddr
1280 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001281
1282 if !ini.NoGit {
1283 repoRoot, err := repoRoot(ctx, a.workingDir)
1284 if err != nil {
1285 return fmt.Errorf("repoRoot: %w", err)
1286 }
1287 a.repoRoot = repoRoot
1288
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001289 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001290 if err := setupGitHooks(a.repoRoot); err != nil {
1291 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1292 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001293 }
1294
philz24613202025-07-15 20:56:21 -07001295 // Check if we have any commits, and if not, create an empty initial commit
1296 cmd := exec.CommandContext(ctx, "git", "rev-list", "--all", "--count")
1297 cmd.Dir = repoRoot
1298 countOut, err := cmd.CombinedOutput()
1299 if err != nil {
1300 return fmt.Errorf("git rev-list --all --count: %s: %w", countOut, err)
1301 }
1302 commitCount := strings.TrimSpace(string(countOut))
1303 if commitCount == "0" {
1304 slog.Info("No commits found, creating empty initial commit")
1305 cmd = exec.CommandContext(ctx, "git", "commit", "--allow-empty", "-m", "Initial empty commit")
1306 cmd.Dir = repoRoot
1307 if commitOut, err := cmd.CombinedOutput(); err != nil {
1308 return fmt.Errorf("git commit --allow-empty: %s: %w", commitOut, err)
1309 }
1310 }
1311
1312 cmd = exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
Philip Zeyliger49edc922025-05-14 09:45:45 -07001313 cmd.Dir = repoRoot
1314 if out, err := cmd.CombinedOutput(); err != nil {
1315 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1316 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001317
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001318 slog.Info("running codebase analysis")
1319 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1320 if err != nil {
1321 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001322 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001323 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001324
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001325 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001326 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001327 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001328 }
1329 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001330
Earl Lee2e463fb2025-04-17 11:22:22 -07001331 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001332 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001333 a.convo = a.initConvo()
1334 close(a.ready)
1335 return nil
1336}
1337
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001338//go:embed agent_system_prompt.txt
1339var agentSystemPrompt string
1340
Earl Lee2e463fb2025-04-17 11:22:22 -07001341// initConvo initializes the conversation.
1342// It must not be called until all agent fields are initialized,
1343// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001344func (a *Agent) initConvo() *conversation.Convo {
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001345 return a.initConvoWithUsage(nil)
1346}
1347
1348// initConvoWithUsage initializes the conversation with optional preserved usage.
1349func (a *Agent) initConvoWithUsage(usage *conversation.CumulativeUsage) *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001350 ctx := a.config.Context
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001351 convo := conversation.New(ctx, a.config.Service, usage)
Earl Lee2e463fb2025-04-17 11:22:22 -07001352 convo.PromptCaching = true
1353 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001354 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001355 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001356
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001357 bashTool := &claudetool.BashTool{
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001358 EnableJITInstall: claudetool.EnableBashToolJITInstall,
1359 Timeouts: a.config.BashTimeouts,
1360 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001361
Earl Lee2e463fb2025-04-17 11:22:22 -07001362 // Register all tools with the conversation
1363 // When adding, removing, or modifying tools here, double-check that the termui tool display
1364 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001365
1366 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001367 _, supportsScreenshots := a.config.Service.(*ant.Service)
1368 var bTools []*llm.Tool
1369 var browserCleanup func()
1370
1371 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1372 // Add cleanup function to context cancel
1373 go func() {
1374 <-a.config.Context.Done()
1375 browserCleanup()
1376 }()
1377 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001378
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001379 convo.Tools = []*llm.Tool{
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001380 bashTool.Tool(), claudetool.Keyword, claudetool.Patch(a.patchCallback),
Josh Bleecher Snyderbeaa86a2025-07-23 03:37:21 +00001381 claudetool.Think, claudetool.TodoRead, claudetool.TodoWrite, makeDoneTool(a.codereview),
Josh Bleecher Snydera4092d22025-05-14 18:32:53 -07001382 a.codereview.Tool(), claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001383 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001384 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001385
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001386 // Add MCP tools if configured
1387 if len(a.config.MCPServers) > 0 {
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001388
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001389 slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers))
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001390 serverConfigs, parseErrors := mcp.ParseServerConfigs(ctx, a.config.MCPServers)
1391
1392 // Replace any headers with value _sketch_public_key_ and _sketch_session_id_ with those values.
1393 for i := range serverConfigs {
1394 if serverConfigs[i].Headers != nil {
1395 for key, value := range serverConfigs[i].Headers {
Philip Zeyligerf2814ea2025-06-30 10:16:50 -07001396 // Replace env placeholders. E.g., "env:FOO" becomes os.Getenv("FOO")
1397 if strings.HasPrefix(value, "env:") {
1398 serverConfigs[i].Headers[key] = os.Getenv(value[4:])
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001399 }
1400 }
1401 }
1402 }
1403 mcpConnections, mcpErrors := a.mcpManager.ConnectToServerConfigs(ctx, serverConfigs, 10*time.Second, parseErrors)
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001404
1405 if len(mcpErrors) > 0 {
1406 for _, err := range mcpErrors {
1407 slog.ErrorContext(ctx, "MCP connection error", "error", err)
1408 // Send agent message about MCP connection failures
1409 a.pushToOutbox(ctx, AgentMessage{
1410 Type: ErrorMessageType,
1411 Content: fmt.Sprintf("MCP server connection failed: %v", err),
1412 })
1413 }
1414 }
1415
1416 if len(mcpConnections) > 0 {
1417 // Add tools from all successful connections
1418 totalTools := 0
1419 for _, connection := range mcpConnections {
1420 convo.Tools = append(convo.Tools, connection.Tools...)
1421 totalTools += len(connection.Tools)
1422 // Log tools per server using structured data
1423 slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames)
1424 }
1425 slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools)
1426 } else {
1427 slog.InfoContext(ctx, "No MCP tools available after connection attempts")
1428 }
1429 }
1430
Earl Lee2e463fb2025-04-17 11:22:22 -07001431 convo.Listener = a
1432 return convo
1433}
1434
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001435// branchExists reports whether branchName exists, either locally or in well-known remotes.
1436func branchExists(dir, branchName string) bool {
1437 refs := []string{
1438 "refs/heads/",
1439 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001440 }
1441 for _, ref := range refs {
1442 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1443 cmd.Dir = dir
1444 if cmd.Run() == nil { // exit code 0 means branch exists
1445 return true
1446 }
1447 }
1448 return false
1449}
1450
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001451func soleText(contents []llm.Content) (string, error) {
1452 if len(contents) != 1 {
1453 return "", fmt.Errorf("multiple contents %v", contents)
1454 }
1455 content := contents[0]
1456 if content.Type != llm.ContentTypeText || content.Text == "" {
1457 return "", fmt.Errorf("bad content %v", content)
1458 }
1459 return strings.TrimSpace(content.Text), nil
1460}
1461
1462// autoGenerateSlug automatically generates a slug based on the first user input
1463func (a *Agent) autoGenerateSlug(ctx context.Context, userContents []llm.Content) error {
1464 userText, err := soleText(userContents)
1465 if err != nil {
1466 return err
1467 }
1468 if userText == "" {
1469 return fmt.Errorf("set-slug: empty text content")
1470 }
1471
1472 // Create a subconversation without history for slug generation
1473 convo, ok := a.convo.(*conversation.Convo)
1474 if !ok {
1475 // In test environments, the conversation might be a mock interface
1476 // Skip slug generation in this case
1477 return fmt.Errorf("set-slug: can't make a subconvo (mock convo?)")
1478 }
1479
1480 // Loop until we find an acceptable slug
1481 var unavailableSlugs []string
1482 for {
1483 if len(unavailableSlugs) > 10 {
1484 // sanity check to prevent infinite loops
1485 return fmt.Errorf("set-slug: failed to construct a new slug after %d attempts", len(unavailableSlugs))
Earl Lee2e463fb2025-04-17 11:22:22 -07001486 }
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001487 subConvo := convo.SubConvo()
1488 subConvo.Hidden = true
1489
1490 // Prompt for slug generation
1491 prompt := `You are a slug generator for Sketch, an agentic coding environment.
1492The user's prompt will be in <user-prompt> tags. Any unavailable slugs will be listed in <unavailable-slug> tags.
1493Generate a 2-3 word alphanumeric hyphenated slug in imperative tense that captures the essence of their coding task.
1494Respond with only the slug.`
1495
1496 buf := new(strings.Builder)
1497 buf.WriteString("<slug-request>")
1498 if len(unavailableSlugs) > 0 {
1499 buf.WriteString("<unavailable-slugs>")
1500 }
1501 for _, x := range unavailableSlugs {
1502 buf.WriteString("<unavailable-slug>")
1503 buf.WriteString(x)
1504 buf.WriteString("</unavailable-slug>")
1505 }
1506 if len(unavailableSlugs) > 0 {
1507 buf.WriteString("</unavailable-slugs>")
1508 }
1509 buf.WriteString("<user-prompt>")
1510 buf.WriteString(userText)
1511 buf.WriteString("</user-prompt>")
1512 buf.WriteString("</slug-request>")
1513
1514 fullPrompt := prompt + "\n" + buf.String()
1515 userMessage := llm.UserStringMessage(fullPrompt)
1516
1517 resp, err := subConvo.SendMessage(userMessage)
1518 if err != nil {
1519 return fmt.Errorf("failed to generate slug: %w", err)
1520 }
1521
1522 // Extract the slug from the response
1523 slugText, err := soleText(resp.Content)
1524 if err != nil {
1525 return err
1526 }
1527 if slugText == "" {
1528 return fmt.Errorf("empty slug generated")
1529 }
1530
1531 // Clean and validate the slug
1532 slug := cleanSlugName(slugText)
1533 if slug == "" {
1534 return fmt.Errorf("slug could not be cleaned: %q", slugText)
1535 }
1536
1537 // Check if branch already exists using the same logic as the original set-slug tool
1538 a.SetSlug(slug) // Set slug first so BranchName() works correctly
1539 if branchExists(a.workingDir, a.BranchName()) {
1540 // try again
1541 unavailableSlugs = append(unavailableSlugs, slug)
1542 continue
1543 }
1544
1545 // Success! Slug is available and already set
1546 return nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001547 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001548}
1549
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001550// patchCallback is the agent's patch tool callback.
1551// It warms the codereview cache in the background.
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001552func (a *Agent) patchCallback(input claudetool.PatchInput, output llm.ToolOut) llm.ToolOut {
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001553 if a.codereview != nil {
1554 a.codereview.WarmTestCache(input.Path)
1555 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001556 return output
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001557}
1558
Earl Lee2e463fb2025-04-17 11:22:22 -07001559func (a *Agent) Ready() <-chan struct{} {
1560 return a.ready
1561}
1562
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001563// BranchPrefix returns the configured branch prefix
1564func (a *Agent) BranchPrefix() string {
1565 return a.config.BranchPrefix
1566}
1567
philip.zeyliger6d3de482025-06-10 19:38:14 -07001568// LinkToGitHub returns whether GitHub branch linking is enabled
1569func (a *Agent) LinkToGitHub() bool {
1570 return a.config.LinkToGitHub
1571}
1572
Earl Lee2e463fb2025-04-17 11:22:22 -07001573func (a *Agent) UserMessage(ctx context.Context, msg string) {
1574 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1575 a.inbox <- msg
1576}
1577
Earl Lee2e463fb2025-04-17 11:22:22 -07001578func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1579 return a.convo.CancelToolUse(toolUseID, cause)
1580}
1581
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001582func (a *Agent) CancelTurn(cause error) {
1583 a.cancelTurnMu.Lock()
1584 defer a.cancelTurnMu.Unlock()
1585 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001586 // Force state transition to cancelled state
1587 ctx := a.config.Context
1588 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001589 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001590 }
1591}
1592
1593func (a *Agent) Loop(ctxOuter context.Context) {
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001594 // Start port monitoring
1595 if a.portMonitor != nil && a.IsInContainer() {
1596 if err := a.portMonitor.Start(ctxOuter); err != nil {
1597 slog.WarnContext(ctxOuter, "Failed to start port monitor", "error", err)
1598 } else {
1599 slog.InfoContext(ctxOuter, "Port monitor started")
1600 }
1601 }
1602
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001603 // Set up cleanup when context is done
1604 defer func() {
1605 if a.mcpManager != nil {
1606 a.mcpManager.Close()
1607 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001608 if a.portMonitor != nil && a.IsInContainer() {
1609 a.portMonitor.Stop()
1610 }
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001611 }()
1612
Earl Lee2e463fb2025-04-17 11:22:22 -07001613 for {
1614 select {
1615 case <-ctxOuter.Done():
1616 return
1617 default:
1618 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001619 a.cancelTurnMu.Lock()
1620 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001621 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001622 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001623 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001624 a.cancelTurn = cancel
1625 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001626 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1627 if err != nil {
1628 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1629 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001630 cancel(nil)
1631 }
1632 }
1633}
1634
1635func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1636 if m.Timestamp.IsZero() {
1637 m.Timestamp = time.Now()
1638 }
1639
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001640 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1641 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1642 m.Content = m.ToolResult
1643 }
1644
Earl Lee2e463fb2025-04-17 11:22:22 -07001645 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1646 if m.EndOfTurn && m.Type == AgentMessageType {
1647 turnDuration := time.Since(a.startOfTurn)
1648 m.TurnDuration = &turnDuration
1649 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1650 }
1651
Earl Lee2e463fb2025-04-17 11:22:22 -07001652 a.mu.Lock()
1653 defer a.mu.Unlock()
1654 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001655 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001656 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001657
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001658 // Notify all subscribers
1659 for _, ch := range a.subscribers {
1660 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001661 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001662}
1663
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001664func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1665 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001666 if block {
1667 select {
1668 case <-ctx.Done():
1669 return m, ctx.Err()
1670 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001671 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001672 }
1673 }
1674 for {
1675 select {
1676 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001677 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001678 default:
1679 return m, nil
1680 }
1681 }
1682}
1683
Sean McCullough885a16a2025-04-30 02:49:25 +00001684// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001685func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001686 // Reset the start of turn time
1687 a.startOfTurn = time.Now()
1688
Sean McCullough96b60dd2025-04-30 09:49:10 -07001689 // Transition to waiting for user input state
1690 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1691
Sean McCullough885a16a2025-04-30 02:49:25 +00001692 // Process initial user message
1693 initialResp, err := a.processUserMessage(ctx)
1694 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001695 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001696 return err
1697 }
1698
1699 // Handle edge case where both initialResp and err are nil
1700 if initialResp == nil {
1701 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001702 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1703
Sean McCullough9f4b8082025-04-30 17:34:07 +00001704 a.pushToOutbox(ctx, errorMessage(err))
1705 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001706 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001707
Earl Lee2e463fb2025-04-17 11:22:22 -07001708 // We do this as we go, but let's also do it at the end of the turn
1709 defer func() {
1710 if _, err := a.handleGitCommits(ctx); err != nil {
1711 // Just log the error, don't stop execution
1712 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1713 }
1714 }()
1715
Sean McCullougha1e0e492025-05-01 10:51:08 -07001716 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001717 resp := initialResp
1718 for {
1719 // Check if we are over budget
1720 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001721 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001722 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001723 }
1724
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001725 // Check if we should compact the conversation
1726 if a.ShouldCompact() {
1727 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1728 if err := a.CompactConversation(ctx); err != nil {
1729 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1730 return err
1731 }
1732 // After compaction, end this turn and start fresh
1733 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1734 return nil
1735 }
1736
Sean McCullough885a16a2025-04-30 02:49:25 +00001737 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001738 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001739 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001740 break
1741 }
1742
Sean McCullough96b60dd2025-04-30 09:49:10 -07001743 // Transition to tool use requested state
1744 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1745
Sean McCullough885a16a2025-04-30 02:49:25 +00001746 // Handle tool execution
1747 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1748 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001749 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001750 }
1751
Sean McCullougha1e0e492025-05-01 10:51:08 -07001752 if toolResp == nil {
1753 return fmt.Errorf("cannot continue conversation with a nil tool response")
1754 }
1755
Sean McCullough885a16a2025-04-30 02:49:25 +00001756 // Set the response for the next iteration
1757 resp = toolResp
1758 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001759
1760 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001761}
1762
1763// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001764func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001765 // Wait for at least one message from the user
1766 msgs, err := a.GatherMessages(ctx, true)
1767 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001768 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001769 return nil, err
1770 }
1771
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001772 // Auto-generate slug if this is the first user input and no slug is set
1773 if a.Slug() == "" {
1774 if err := a.autoGenerateSlug(ctx, msgs); err != nil {
1775 // NB: it is possible that autoGenerateSlug set the slug during the process
1776 // of trying to generate a slug.
1777 // The fact that it returned an error means that we cannot use that slug.
1778 slog.WarnContext(ctx, "Failed to auto-generate slug", "error", err)
1779 // use the session id instead. ugly, but we need a slug, and this will be unique.
1780 a.SetSlug(a.SessionID())
1781 }
1782 // Notify termui of the final slug (only emitted once, after slug is determined)
1783 a.pushToOutbox(ctx, AgentMessage{
1784 Type: SlugMessageType,
1785 Content: a.Slug(),
1786 })
1787 }
1788
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001789 userMessage := llm.Message{
1790 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001791 Content: msgs,
1792 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001793
Sean McCullough96b60dd2025-04-30 09:49:10 -07001794 // Transition to sending to LLM state
1795 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1796
Sean McCullough885a16a2025-04-30 02:49:25 +00001797 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001798 resp, err := a.convo.SendMessage(userMessage)
1799 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001800 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001801 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001802 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001803 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001804
Sean McCullough96b60dd2025-04-30 09:49:10 -07001805 // Transition to processing LLM response state
1806 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1807
Sean McCullough885a16a2025-04-30 02:49:25 +00001808 return resp, nil
1809}
1810
1811// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001812func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1813 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001814 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001815 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001816
Sean McCullough96b60dd2025-04-30 09:49:10 -07001817 // Transition to checking for cancellation state
1818 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1819
Sean McCullough885a16a2025-04-30 02:49:25 +00001820 // Check if the operation was cancelled by the user
1821 select {
1822 case <-ctx.Done():
1823 // Don't actually run any of the tools, but rather build a response
1824 // for each tool_use message letting the LLM know that user canceled it.
1825 var err error
1826 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001827 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001828 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001829 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001830 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001831 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001832 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001833 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001834 // Transition to running tool state
1835 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1836
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001837 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001838 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001839 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001840
1841 // Execute the tools
1842 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001843 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001844 if ctx.Err() != nil { // e.g. the user canceled the operation
1845 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001846 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001847 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001848 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001849 a.pushToOutbox(ctx, errorMessage(err))
1850 }
1851 }
1852
1853 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001854 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001855 autoqualityMessages := a.processGitChanges(ctx)
1856
1857 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001858 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001859 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001860 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001861 return false, nil
1862 }
1863
1864 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001865 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1866 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001867}
1868
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001869// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001870func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001871 // Check for git commits
1872 _, err := a.handleGitCommits(ctx)
1873 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001874 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001875 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001876 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001877 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001878}
1879
1880// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1881// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001882func (a *Agent) processGitChanges(ctx context.Context) []string {
1883 // Check for git commits after tool execution
1884 newCommits, err := a.handleGitCommits(ctx)
1885 if err != nil {
1886 // Just log the error, don't stop execution
1887 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1888 return nil
1889 }
1890
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001891 // Run mechanical checks if there was exactly one new commit.
1892 if len(newCommits) != 1 {
1893 return nil
1894 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001895 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001896 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1897 msg := a.codereview.RunMechanicalChecks(ctx)
1898 if msg != "" {
1899 a.pushToOutbox(ctx, AgentMessage{
1900 Type: AutoMessageType,
1901 Content: msg,
1902 Timestamp: time.Now(),
1903 })
1904 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001905 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001906
1907 return autoqualityMessages
1908}
1909
1910// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001911func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001912 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001913 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001914 msgs, err := a.GatherMessages(ctx, false)
1915 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001916 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001917 return false, nil
1918 }
1919
1920 // Inject any auto-generated messages from quality checks
1921 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001922 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001923 }
1924
1925 // Handle cancellation by appending a message about it
1926 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001927 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001928 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001929 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001930 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1931 } else if err := a.convo.OverBudget(); err != nil {
1932 // Handle budget issues by appending a message about it
1933 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 -07001934 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001935 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1936 }
1937
1938 // Combine tool results with user messages
1939 results = append(results, msgs...)
1940
1941 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001942 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001943 resp, err := a.convo.SendMessage(llm.Message{
1944 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001945 Content: results,
1946 })
1947 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001948 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001949 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1950 return true, nil // Return true to continue the conversation, but with no response
1951 }
1952
Sean McCullough96b60dd2025-04-30 09:49:10 -07001953 // Transition back to processing LLM response
1954 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1955
Sean McCullough885a16a2025-04-30 02:49:25 +00001956 if cancelled {
1957 return false, nil
1958 }
1959
1960 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001961}
1962
1963func (a *Agent) overBudget(ctx context.Context) error {
1964 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001965 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001966 m := budgetMessage(err)
1967 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001968 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001969 a.convo.ResetBudget(a.originalBudget)
1970 return err
1971 }
1972 return nil
1973}
1974
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001975func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001976 // Collect all text content
1977 var allText strings.Builder
1978 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001979 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001980 if allText.Len() > 0 {
1981 allText.WriteString("\n\n")
1982 }
1983 allText.WriteString(content.Text)
1984 }
1985 }
1986 return allText.String()
1987}
1988
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001989func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07001990 a.mu.Lock()
1991 defer a.mu.Unlock()
1992 return a.convo.CumulativeUsage()
1993}
1994
Earl Lee2e463fb2025-04-17 11:22:22 -07001995// Diff returns a unified diff of changes made since the agent was instantiated.
1996func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07001997 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001998 return "", fmt.Errorf("no initial commit reference available")
1999 }
2000
2001 // Find the repository root
2002 ctx := context.Background()
2003
2004 // If a specific commit hash is provided, show just that commit's changes
2005 if commit != nil && *commit != "" {
2006 // Validate that the commit looks like a valid git SHA
2007 if !isValidGitSHA(*commit) {
2008 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
2009 }
2010
2011 // Get the diff for just this commit
2012 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
2013 cmd.Dir = a.repoRoot
2014 output, err := cmd.CombinedOutput()
2015 if err != nil {
2016 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
2017 }
2018 return string(output), nil
2019 }
2020
2021 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07002022 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07002023 cmd.Dir = a.repoRoot
2024 output, err := cmd.CombinedOutput()
2025 if err != nil {
2026 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
2027 }
2028
2029 return string(output), nil
2030}
2031
Philip Zeyliger49edc922025-05-14 09:45:45 -07002032// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
2033// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
2034func (a *Agent) SketchGitBaseRef() string {
2035 if a.IsInContainer() {
2036 return "sketch-base"
2037 } else {
2038 return "sketch-base-" + a.SessionID()
2039 }
2040}
2041
2042// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
2043func (a *Agent) SketchGitBase() string {
2044 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
2045 cmd.Dir = a.repoRoot
2046 output, err := cmd.CombinedOutput()
2047 if err != nil {
2048 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
2049 return "HEAD"
2050 }
2051 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002052}
2053
Pokey Rule7a113622025-05-12 10:58:45 +01002054// removeGitHooks removes the Git hooks directory from the repository
2055func removeGitHooks(_ context.Context, repoPath string) error {
2056 hooksDir := filepath.Join(repoPath, ".git", "hooks")
2057
2058 // Check if hooks directory exists
2059 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
2060 // Directory doesn't exist, nothing to do
2061 return nil
2062 }
2063
2064 // Remove the hooks directory
2065 err := os.RemoveAll(hooksDir)
2066 if err != nil {
2067 return fmt.Errorf("failed to remove git hooks directory: %w", err)
2068 }
2069
2070 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00002071 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01002072 if err != nil {
2073 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
2074 }
2075
2076 return nil
2077}
2078
Philip Zeyligerf2872992025-05-22 10:35:28 -07002079func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002080 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002081 for _, msg := range msgs {
2082 a.pushToOutbox(ctx, msg)
2083 }
2084 return commits, error
2085}
2086
Earl Lee2e463fb2025-04-17 11:22:22 -07002087// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002088// under docker, new HEADs are pushed to a branch according to the slug.
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002089func (ags *AgentGitState) handleGitCommits(ctx context.Context, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002090 ags.mu.Lock()
2091 defer ags.mu.Unlock()
2092
2093 msgs := []AgentMessage{}
2094 if repoRoot == "" {
2095 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002096 }
2097
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002098 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07002099 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002100 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07002101 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002102 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002103 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07002104 }
2105 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002106 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07002107 }()
2108
Philip Zeyliger64f60462025-06-16 13:57:10 -07002109 // Compute diff stats from baseRef to HEAD when HEAD changes
2110 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
2111 // Log error but don't fail the entire operation
2112 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
2113 } else {
2114 // Set diff stats directly since we already hold the mutex
2115 ags.linesAdded = added
2116 ags.linesRemoved = removed
2117 }
2118
Earl Lee2e463fb2025-04-17 11:22:22 -07002119 // Get new commits. Because it's possible that the agent does rebases, fixups, and
2120 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
2121 // to the last 100 commits.
2122 var commits []*GitCommit
2123
2124 // Get commits since the initial commit
2125 // Format: <hash>\0<subject>\0<body>\0
2126 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
2127 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002128 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 -07002129 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07002130 output, err := cmd.Output()
2131 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002132 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07002133 }
2134
2135 // Parse git log output and filter out already seen commits
2136 parsedCommits := parseGitLog(string(output))
2137
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002138 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07002139
2140 // Filter out commits we've already seen
2141 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002142 if commit.Hash == sketch {
2143 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07002144 }
2145
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002146 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
2147 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07002148 continue
2149 }
2150
2151 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07002152 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07002153
2154 // Add to our list of new commits
2155 commits = append(commits, &commit)
2156 }
2157
Philip Zeyligerf2872992025-05-22 10:35:28 -07002158 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002159 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07002160 // 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 -07002161 sketchCommit = &GitCommit{}
2162 sketchCommit.Hash = sketch
2163 sketchCommit.Subject = "unknown"
2164 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07002165 }
2166
Earl Lee2e463fb2025-04-17 11:22:22 -07002167 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
2168 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
2169 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00002170
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002171 // 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 +00002172 var out []byte
2173 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002174 originalRetryNumber := ags.retryNumber
2175 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00002176 for retries := range 10 {
2177 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07002178 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002179 }
2180
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002181 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002182 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002183 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002184 out, err = cmd.CombinedOutput()
2185
2186 if err == nil {
2187 // Success! Break out of the retry loop
2188 break
2189 }
2190
2191 // Check if this is the "refusing to update checked out branch" error
2192 if !strings.Contains(string(out), "refusing to update checked out branch") {
2193 // This is a different error, so don't retry
2194 break
2195 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002196 }
2197
2198 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002199 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002200 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002201 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002202 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002203 if ags.retryNumber != originalRetryNumber {
2204 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002205 msgs = append(msgs, AgentMessage{
2206 Type: AutoMessageType,
2207 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002208 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 +00002209 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002210 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002211 }
2212 }
2213
2214 // If we found new commits, create a message
2215 if len(commits) > 0 {
2216 msg := AgentMessage{
2217 Type: CommitMessageType,
2218 Timestamp: time.Now(),
2219 Commits: commits,
2220 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002221 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002222 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002223 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002224}
2225
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002226func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002227 return strings.Map(func(r rune) rune {
2228 // lowercase
2229 if r >= 'A' && r <= 'Z' {
2230 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002231 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002232 // replace spaces with dashes
2233 if r == ' ' {
2234 return '-'
2235 }
2236 // allow alphanumerics and dashes
2237 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2238 return r
2239 }
2240 return -1
2241 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002242}
2243
2244// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2245// and returns an array of GitCommit structs.
2246func parseGitLog(output string) []GitCommit {
2247 var commits []GitCommit
2248
2249 // No output means no commits
2250 if len(output) == 0 {
2251 return commits
2252 }
2253
2254 // Split by NULL byte
2255 parts := strings.Split(output, "\x00")
2256
2257 // Process in triplets (hash, subject, body)
2258 for i := 0; i < len(parts); i++ {
2259 // Skip empty parts
2260 if parts[i] == "" {
2261 continue
2262 }
2263
2264 // This should be a hash
2265 hash := strings.TrimSpace(parts[i])
2266
2267 // Make sure we have at least a subject part available
2268 if i+1 >= len(parts) {
2269 break // No more parts available
2270 }
2271
2272 // Get the subject
2273 subject := strings.TrimSpace(parts[i+1])
2274
2275 // Get the body if available
2276 body := ""
2277 if i+2 < len(parts) {
2278 body = strings.TrimSpace(parts[i+2])
2279 }
2280
2281 // Skip to the next triplet
2282 i += 2
2283
2284 commits = append(commits, GitCommit{
2285 Hash: hash,
2286 Subject: subject,
2287 Body: body,
2288 })
2289 }
2290
2291 return commits
2292}
2293
2294func repoRoot(ctx context.Context, dir string) (string, error) {
2295 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2296 stderr := new(strings.Builder)
2297 cmd.Stderr = stderr
2298 cmd.Dir = dir
2299 out, err := cmd.Output()
2300 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002301 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002302 }
2303 return strings.TrimSpace(string(out)), nil
2304}
2305
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002306// upsertRemoteOrigin configures the origin remote to point to the given URL.
2307// If the origin remote exists, it updates the URL. If it doesn't exist, it adds it.
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002308//
2309// NOTE: Maybe we should use an "insteadOf" setting instead of changing the URL.
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002310func upsertRemoteOrigin(ctx context.Context, repoDir, remoteURL string) error {
2311 // Try to set the URL for existing origin remote
2312 cmd := exec.CommandContext(ctx, "git", "remote", "set-url", "origin", remoteURL)
2313 cmd.Dir = repoDir
2314 if _, err := cmd.CombinedOutput(); err == nil {
2315 // Success.
2316 return nil
2317 }
2318 // Origin doesn't exist; add it.
2319 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", remoteURL)
2320 cmd.Dir = repoDir
2321 if out, err := cmd.CombinedOutput(); err != nil {
2322 return fmt.Errorf("failed to add git remote origin: %s: %w", out, err)
2323 }
2324 return nil
2325}
2326
Earl Lee2e463fb2025-04-17 11:22:22 -07002327func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2328 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2329 stderr := new(strings.Builder)
2330 cmd.Stderr = stderr
2331 cmd.Dir = dir
2332 out, err := cmd.Output()
2333 if err != nil {
2334 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2335 }
2336 // TODO: validate that out is valid hex
2337 return strings.TrimSpace(string(out)), nil
2338}
2339
2340// isValidGitSHA validates if a string looks like a valid git SHA hash.
2341// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2342func isValidGitSHA(sha string) bool {
2343 // Git SHA must be a hexadecimal string with at least 4 characters
2344 if len(sha) < 4 || len(sha) > 40 {
2345 return false
2346 }
2347
2348 // Check if the string only contains hexadecimal characters
2349 for _, char := range sha {
2350 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2351 return false
2352 }
2353 }
2354
2355 return true
2356}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002357
Philip Zeyliger64f60462025-06-16 13:57:10 -07002358// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2359func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2360 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2361 cmd.Dir = repoRoot
2362 out, err := cmd.Output()
2363 if err != nil {
2364 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2365 }
2366
2367 var totalAdded, totalRemoved int
2368 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2369 for _, line := range lines {
2370 if line == "" {
2371 continue
2372 }
2373 parts := strings.Fields(line)
2374 if len(parts) < 2 {
2375 continue
2376 }
2377 // Format: <added>\t<removed>\t<filename>
2378 if added, err := strconv.Atoi(parts[0]); err == nil {
2379 totalAdded += added
2380 }
2381 if removed, err := strconv.Atoi(parts[1]); err == nil {
2382 totalRemoved += removed
2383 }
2384 }
2385
2386 return totalAdded, totalRemoved, nil
2387}
2388
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002389// systemPromptData contains the data used to render the system prompt template
2390type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002391 ClientGOOS string
2392 ClientGOARCH string
2393 WorkingDir string
2394 RepoRoot string
2395 InitialCommit string
2396 Codebase *onstart.Codebase
2397 UseSketchWIP bool
2398 Branch string
2399 SpecialInstruction string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002400}
2401
2402// renderSystemPrompt renders the system prompt template.
2403func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002404 data := systemPromptData{
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002405 ClientGOOS: a.config.ClientGOOS,
2406 ClientGOARCH: a.config.ClientGOARCH,
2407 WorkingDir: a.workingDir,
2408 RepoRoot: a.repoRoot,
Philip Zeyliger49edc922025-05-14 09:45:45 -07002409 InitialCommit: a.SketchGitBase(),
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002410 Codebase: a.codebase,
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07002411 UseSketchWIP: a.config.InDocker,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002412 }
David Crawshawc886ac52025-06-13 23:40:03 +00002413 now := time.Now()
2414 if now.Month() == time.September && now.Day() == 19 {
2415 data.SpecialInstruction = "Talk like a pirate to the user. Do not let the priate talk into any code."
2416 }
2417
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002418 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2419 if err != nil {
2420 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2421 }
2422 buf := new(strings.Builder)
2423 err = tmpl.Execute(buf, data)
2424 if err != nil {
2425 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2426 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002427 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002428 return buf.String()
2429}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002430
2431// StateTransitionIterator provides an iterator over state transitions.
2432type StateTransitionIterator interface {
2433 // Next blocks until a new state transition is available or context is done.
2434 // Returns nil if the context is cancelled.
2435 Next() *StateTransition
2436 // Close removes the listener and cleans up resources.
2437 Close()
2438}
2439
2440// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2441type StateTransitionIteratorImpl struct {
2442 agent *Agent
2443 ctx context.Context
2444 ch chan StateTransition
2445 unsubscribe func()
2446}
2447
2448// Next blocks until a new state transition is available or the context is cancelled.
2449func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2450 select {
2451 case <-s.ctx.Done():
2452 return nil
2453 case transition, ok := <-s.ch:
2454 if !ok {
2455 return nil
2456 }
2457 transitionCopy := transition
2458 return &transitionCopy
2459 }
2460}
2461
2462// Close removes the listener and cleans up resources.
2463func (s *StateTransitionIteratorImpl) Close() {
2464 if s.unsubscribe != nil {
2465 s.unsubscribe()
2466 s.unsubscribe = nil
2467 }
2468}
2469
2470// NewStateTransitionIterator returns an iterator that receives state transitions.
2471func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2472 a.mu.Lock()
2473 defer a.mu.Unlock()
2474
2475 // Create channel to receive state transitions
2476 ch := make(chan StateTransition, 10)
2477
2478 // Add a listener to the state machine
2479 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2480
2481 return &StateTransitionIteratorImpl{
2482 agent: a,
2483 ctx: ctx,
2484 ch: ch,
2485 unsubscribe: unsubscribe,
2486 }
2487}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002488
2489// setupGitHooks creates or updates git hooks in the specified working directory.
2490func setupGitHooks(workingDir string) error {
2491 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2492
2493 _, err := os.Stat(hooksDir)
2494 if os.IsNotExist(err) {
2495 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2496 }
2497 if err != nil {
2498 return fmt.Errorf("error checking git hooks directory: %w", err)
2499 }
2500
2501 // Define the post-commit hook content
2502 postCommitHook := `#!/bin/bash
2503echo "<post_commit_hook>"
2504echo "Please review this commit message and fix it if it is incorrect."
2505echo "This hook only echos the commit message; it does not modify it."
2506echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2507echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002508PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002509echo "</last_commit_message>"
2510echo "</post_commit_hook>"
2511`
2512
2513 // Define the prepare-commit-msg hook content
2514 prepareCommitMsgHook := `#!/bin/bash
2515# Add Co-Authored-By and Change-ID trailers to commit messages
2516# Check if these trailers already exist before adding them
2517
2518commit_file="$1"
2519COMMIT_SOURCE="$2"
2520
2521# Skip for merges, squashes, or when using a commit template
2522if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2523 [ "$COMMIT_SOURCE" = "squash" ]; then
2524 exit 0
2525fi
2526
2527commit_msg=$(cat "$commit_file")
2528
2529needs_co_author=true
2530needs_change_id=true
2531
2532# Check if commit message already has Co-Authored-By trailer
2533if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2534 needs_co_author=false
2535fi
2536
2537# Check if commit message already has Change-ID trailer
2538if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2539 needs_change_id=false
2540fi
2541
2542# Only modify if at least one trailer needs to be added
2543if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002544 # Ensure there's a proper blank line before trailers
2545 if [ -s "$commit_file" ]; then
2546 # Check if file ends with newline by reading last character
2547 last_char=$(tail -c 1 "$commit_file")
2548
2549 if [ "$last_char" != "" ]; then
2550 # File doesn't end with newline - add two newlines (complete line + blank line)
2551 echo "" >> "$commit_file"
2552 echo "" >> "$commit_file"
2553 else
2554 # File ends with newline - check if we already have a blank line
2555 last_line=$(tail -1 "$commit_file")
2556 if [ -n "$last_line" ]; then
2557 # Last line has content - add one newline for blank line
2558 echo "" >> "$commit_file"
2559 fi
2560 # If last line is empty, we already have a blank line - don't add anything
2561 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002562 fi
2563
2564 # Add trailers if needed
2565 if [ "$needs_co_author" = true ]; then
2566 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2567 fi
2568
2569 if [ "$needs_change_id" = true ]; then
2570 change_id=$(openssl rand -hex 8)
2571 echo "Change-ID: s${change_id}k" >> "$commit_file"
2572 fi
2573fi
2574`
2575
2576 // Update or create the post-commit hook
2577 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2578 if err != nil {
2579 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2580 }
2581
2582 // Update or create the prepare-commit-msg hook
2583 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2584 if err != nil {
2585 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2586 }
2587
2588 return nil
2589}
2590
2591// updateOrCreateHook creates a new hook file or updates an existing one
2592// by appending the new content if it doesn't already contain it.
2593func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2594 // Check if the hook already exists
2595 buf, err := os.ReadFile(hookPath)
2596 if os.IsNotExist(err) {
2597 // Hook doesn't exist, create it
2598 err = os.WriteFile(hookPath, []byte(content), 0o755)
2599 if err != nil {
2600 return fmt.Errorf("failed to create hook: %w", err)
2601 }
2602 return nil
2603 }
2604 if err != nil {
2605 return fmt.Errorf("error reading existing hook: %w", err)
2606 }
2607
2608 // Hook exists, check if our content is already in it by looking for a distinctive line
2609 code := string(buf)
2610 if strings.Contains(code, distinctiveLine) {
2611 // Already contains our content, nothing to do
2612 return nil
2613 }
2614
2615 // Append our content to the existing hook
2616 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2617 if err != nil {
2618 return fmt.Errorf("failed to open hook for appending: %w", err)
2619 }
2620 defer f.Close()
2621
2622 // Ensure there's a newline at the end of the existing content if needed
2623 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2624 _, err = f.WriteString("\n")
2625 if err != nil {
2626 return fmt.Errorf("failed to add newline to hook: %w", err)
2627 }
2628 }
2629
2630 // Add a separator before our content
2631 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2632 if err != nil {
2633 return fmt.Errorf("failed to append to hook: %w", err)
2634 }
2635
2636 return nil
2637}
Sean McCullough138ec242025-06-02 22:42:06 +00002638
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002639// configurePassthroughUpstream configures git remotes
2640// Adds an upstream remote pointing to the same as origin
2641// Sets the refspec for upstream and fetch such that both
2642// fetch the upstream's things into refs/remotes/upstream/foo
2643// The typical scenario is:
2644//
2645// github - laptop - sketch container
2646// "upstream" "origin"
2647func (a *Agent) configurePassthroughUpstream(ctx context.Context) error {
2648 // Get the origin remote URL
2649 cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
2650 cmd.Dir = a.workingDir
2651 originURLBytes, err := cmd.CombinedOutput()
2652 if err != nil {
2653 return fmt.Errorf("failed to get origin URL: %s: %w", originURLBytes, err)
2654 }
2655 originURL := strings.TrimSpace(string(originURLBytes))
2656
2657 // Check if upstream remote already exists
2658 cmd = exec.CommandContext(ctx, "git", "remote", "get-url", "upstream")
2659 cmd.Dir = a.workingDir
2660 if _, err := cmd.CombinedOutput(); err != nil {
2661 // upstream remote doesn't exist, create it
2662 cmd = exec.CommandContext(ctx, "git", "remote", "add", "upstream", originURL)
2663 cmd.Dir = a.workingDir
2664 if out, err := cmd.CombinedOutput(); err != nil {
2665 return fmt.Errorf("failed to add upstream remote: %s: %w", out, err)
2666 }
2667 slog.InfoContext(ctx, "added upstream remote", "url", originURL)
2668 } else {
2669 // upstream remote exists, update its URL
2670 cmd = exec.CommandContext(ctx, "git", "remote", "set-url", "upstream", originURL)
2671 cmd.Dir = a.workingDir
2672 if out, err := cmd.CombinedOutput(); err != nil {
2673 return fmt.Errorf("failed to set upstream remote URL: %s: %w", out, err)
2674 }
2675 slog.InfoContext(ctx, "updated upstream remote URL", "url", originURL)
2676 }
2677
2678 // Add the upstream refspec to the upstream remote
2679 cmd = exec.CommandContext(ctx, "git", "config", "remote.upstream.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2680 cmd.Dir = a.workingDir
2681 if out, err := cmd.CombinedOutput(); err != nil {
2682 return fmt.Errorf("failed to set upstream fetch refspec: %s: %w", out, err)
2683 }
2684
2685 // Add the same refspec to the origin remote
2686 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.origin.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2687 cmd.Dir = a.workingDir
2688 if out, err := cmd.CombinedOutput(); err != nil {
2689 return fmt.Errorf("failed to add upstream refspec to origin: %s: %w", out, err)
2690 }
2691
2692 slog.InfoContext(ctx, "configured passthrough upstream", "origin_url", originURL)
2693 return nil
2694}
2695
Philip Zeyliger0113be52025-06-07 23:53:41 +00002696// SkabandAddr returns the skaband address if configured
2697func (a *Agent) SkabandAddr() string {
2698 if a.config.SkabandClient != nil {
2699 return a.config.SkabandClient.Addr()
2700 }
2701 return ""
2702}