blob: 4adf80f31cb9c66d59d6410d10e09520660fb844 [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
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000167
168 // ModelName returns the name of the model the agent is using.
169 ModelName() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700170}
171
172type CodingAgentMessageType string
173
174const (
175 UserMessageType CodingAgentMessageType = "user"
176 AgentMessageType CodingAgentMessageType = "agent"
177 ErrorMessageType CodingAgentMessageType = "error"
178 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
179 ToolUseMessageType CodingAgentMessageType = "tool"
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700180 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
181 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
182 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000183 PortMessageType CodingAgentMessageType = "port" // for port monitoring events
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +0000184 SlugMessageType CodingAgentMessageType = "slug" // for slug updates
Earl Lee2e463fb2025-04-17 11:22:22 -0700185
186 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
187)
188
189type AgentMessage struct {
190 Type CodingAgentMessageType `json:"type"`
191 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
192 EndOfTurn bool `json:"end_of_turn"`
193
194 Content string `json:"content"`
195 ToolName string `json:"tool_name,omitempty"`
196 ToolInput string `json:"input,omitempty"`
197 ToolResult string `json:"tool_result,omitempty"`
198 ToolError bool `json:"tool_error,omitempty"`
199 ToolCallId string `json:"tool_call_id,omitempty"`
200
201 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
202 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
203
Sean McCulloughd9f13372025-04-21 15:08:49 -0700204 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
205 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
206
Earl Lee2e463fb2025-04-17 11:22:22 -0700207 // Commits is a list of git commits for a commit message
208 Commits []*GitCommit `json:"commits,omitempty"`
209
210 Timestamp time.Time `json:"timestamp"`
211 ConversationID string `json:"conversation_id"`
212 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700213 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700214
215 // Message timing information
216 StartTime *time.Time `json:"start_time,omitempty"`
217 EndTime *time.Time `json:"end_time,omitempty"`
218 Elapsed *time.Duration `json:"elapsed,omitempty"`
219
220 // Turn duration - the time taken for a complete agent turn
221 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
222
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000223 // HideOutput indicates that this message should not be rendered in the UI.
224 // This is useful for subconversations that generate output that shouldn't be shown to the user.
225 HideOutput bool `json:"hide_output,omitempty"`
226
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700227 // TodoContent contains the agent's todo file content when it has changed
228 TodoContent *string `json:"todo_content,omitempty"`
229
Josh Bleecher Snyder3dd3e412025-07-22 20:32:03 -0700230 // Display contains content to be displayed to the user, set by tools
231 Display any `json:"display,omitempty"`
232
Earl Lee2e463fb2025-04-17 11:22:22 -0700233 Idx int `json:"idx"`
234}
235
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000236// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700237func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700238 if convo == nil {
239 m.ConversationID = ""
240 m.ParentConversationID = nil
241 return
242 }
243 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000244 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700245 if convo.Parent != nil {
246 m.ParentConversationID = &convo.Parent.ID
247 }
248}
249
Earl Lee2e463fb2025-04-17 11:22:22 -0700250// GitCommit represents a single git commit for a commit message
251type GitCommit struct {
252 Hash string `json:"hash"` // Full commit hash
253 Subject string `json:"subject"` // Commit subject line
254 Body string `json:"body"` // Full commit message body
255 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
256}
257
258// ToolCall represents a single tool call within an agent message
259type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700260 Name string `json:"name"`
261 Input string `json:"input"`
262 ToolCallId string `json:"tool_call_id"`
263 ResultMessage *AgentMessage `json:"result_message,omitempty"`
264 Args string `json:"args,omitempty"`
265 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700266}
267
268func (a *AgentMessage) Attr() slog.Attr {
269 var attrs []any = []any{
270 slog.String("type", string(a.Type)),
271 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700272 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700273 if a.EndOfTurn {
274 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
275 }
276 if a.Content != "" {
277 attrs = append(attrs, slog.String("content", a.Content))
278 }
279 if a.ToolName != "" {
280 attrs = append(attrs, slog.String("tool_name", a.ToolName))
281 }
282 if a.ToolInput != "" {
283 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
284 }
285 if a.Elapsed != nil {
286 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
287 }
288 if a.TurnDuration != nil {
289 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
290 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700291 if len(a.ToolResult) > 0 {
292 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700293 }
294 if a.ToolError {
295 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
296 }
297 if len(a.ToolCalls) > 0 {
298 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
299 for i, tc := range a.ToolCalls {
300 toolCallAttrs = append(toolCallAttrs, slog.Group(
301 fmt.Sprintf("tool_call_%d", i),
302 slog.String("name", tc.Name),
303 slog.String("input", tc.Input),
304 ))
305 }
306 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
307 }
308 if a.ConversationID != "" {
309 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
310 }
311 if a.ParentConversationID != nil {
312 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
313 }
314 if a.Usage != nil && !a.Usage.IsZero() {
315 attrs = append(attrs, a.Usage.Attr())
316 }
317 // TODO: timestamp, convo ids, idx?
318 return slog.Group("agent_message", attrs...)
319}
320
321func errorMessage(err error) AgentMessage {
322 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
323 if os.Getenv(("DEBUG")) == "1" {
324 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
325 }
326
327 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
328}
329
330func budgetMessage(err error) AgentMessage {
331 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
332}
333
334// ConvoInterface defines the interface for conversation interactions
335type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700336 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700337 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700338 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700339 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700340 SendMessage(message llm.Message) (*llm.Response, error)
341 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700342 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000343 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700344 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700345 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700346 SubConvoWithHistory() *conversation.Convo
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700347 DebugJSON() ([]byte, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700348}
349
Philip Zeyligerf2872992025-05-22 10:35:28 -0700350// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700351// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700352// any time we notice we need to.
353type AgentGitState struct {
354 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700355 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700356 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000357 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700358 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700359 slug string // Human-readable session identifier
360 retryNumber int // Number to append when branch conflicts occur
Philip Zeyliger64f60462025-06-16 13:57:10 -0700361 linesAdded int // Lines added from sketch-base to HEAD
362 linesRemoved int // Lines removed from sketch-base to HEAD
Philip Zeyligerf2872992025-05-22 10:35:28 -0700363}
364
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700365func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700366 ags.mu.Lock()
367 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700368 if ags.slug != slug {
369 ags.retryNumber = 0
370 }
371 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700372}
373
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700374func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700375 ags.mu.Lock()
376 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700377 return ags.slug
378}
379
380func (ags *AgentGitState) IncrementRetryNumber() {
381 ags.mu.Lock()
382 defer ags.mu.Unlock()
383 ags.retryNumber++
384}
385
Philip Zeyliger64f60462025-06-16 13:57:10 -0700386func (ags *AgentGitState) DiffStats() (int, int) {
387 ags.mu.Lock()
388 defer ags.mu.Unlock()
389 return ags.linesAdded, ags.linesRemoved
390}
391
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700392// HasSeenCommits returns true if any commits have been processed
393func (ags *AgentGitState) HasSeenCommits() bool {
394 ags.mu.Lock()
395 defer ags.mu.Unlock()
396 return len(ags.seenCommits) > 0
397}
398
399func (ags *AgentGitState) RetryNumber() int {
400 ags.mu.Lock()
401 defer ags.mu.Unlock()
402 return ags.retryNumber
403}
404
405func (ags *AgentGitState) BranchName(prefix string) string {
406 ags.mu.Lock()
407 defer ags.mu.Unlock()
408 return ags.branchNameLocked(prefix)
409}
410
411func (ags *AgentGitState) branchNameLocked(prefix string) string {
412 if ags.slug == "" {
413 return ""
414 }
415 if ags.retryNumber == 0 {
416 return prefix + ags.slug
417 }
418 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700419}
420
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000421func (ags *AgentGitState) Upstream() string {
422 ags.mu.Lock()
423 defer ags.mu.Unlock()
424 return ags.upstream
425}
426
Earl Lee2e463fb2025-04-17 11:22:22 -0700427type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700428 convo ConvoInterface
429 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700430 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700431 workingDir string
432 repoRoot string // workingDir may be a subdir of repoRoot
433 url string
434 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000435 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700436 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000437 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700438 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700439 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000440 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700441 // State machine to track agent state
442 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000443 // Outside information
444 outsideHostname string
445 outsideOS string
446 outsideWorkingDir string
Philip Zeyliger194bfa82025-06-24 06:03:06 -0700447 // MCP manager for handling MCP server connections
448 mcpManager *mcp.MCPManager
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000449 // Port monitor for tracking TCP ports
450 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700451
452 // Time when the current turn started (reset at the beginning of InnerLoop)
453 startOfTurn time.Time
454
455 // Inbox - for messages from the user to the agent.
456 // sent on by UserMessage
457 // . e.g. when user types into the chat textarea
458 // read from by GatherMessages
459 inbox chan string
460
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000461 // protects cancelTurn
462 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700463 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000464 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700465
466 // protects following
467 mu sync.Mutex
468
469 // Stores all messages for this agent
470 history []AgentMessage
471
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700472 // Iterators add themselves here when they're ready to be notified of new messages.
473 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700474
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000475 // Track outstanding LLM call IDs
476 outstandingLLMCalls map[string]struct{}
477
478 // Track outstanding tool calls by ID with their names
479 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700480}
481
banksean5ab8fb82025-07-09 12:34:55 -0700482// TokenContextWindow implements CodingAgent.
483func (a *Agent) TokenContextWindow() int {
484 return a.config.Service.TokenContextWindow()
485}
486
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000487// ModelName returns the name of the model the agent is using.
488func (a *Agent) ModelName() string {
489 return a.config.Model
490}
491
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700492// GetConvo returns the conversation interface for debugging purposes.
493func (a *Agent) GetConvo() ConvoInterface {
494 return a.convo
495}
496
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700497// NewIterator implements CodingAgent.
498func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
499 a.mu.Lock()
500 defer a.mu.Unlock()
501
502 return &MessageIteratorImpl{
503 agent: a,
504 ctx: ctx,
505 nextMessageIdx: nextMessageIdx,
506 ch: make(chan *AgentMessage, 100),
507 }
508}
509
510type MessageIteratorImpl struct {
511 agent *Agent
512 ctx context.Context
513 nextMessageIdx int
514 ch chan *AgentMessage
515 subscribed bool
516}
517
518func (m *MessageIteratorImpl) Close() {
519 m.agent.mu.Lock()
520 defer m.agent.mu.Unlock()
521 // Delete ourselves from the subscribers list
522 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
523 return x == m.ch
524 })
525 close(m.ch)
526}
527
528func (m *MessageIteratorImpl) Next() *AgentMessage {
529 // We avoid subscription at creation to let ourselves catch up to "current state"
530 // before subscribing.
531 if !m.subscribed {
532 m.agent.mu.Lock()
533 if m.nextMessageIdx < len(m.agent.history) {
534 msg := &m.agent.history[m.nextMessageIdx]
535 m.nextMessageIdx++
536 m.agent.mu.Unlock()
537 return msg
538 }
539 // The next message doesn't exist yet, so let's subscribe
540 m.agent.subscribers = append(m.agent.subscribers, m.ch)
541 m.subscribed = true
542 m.agent.mu.Unlock()
543 }
544
545 for {
546 select {
547 case <-m.ctx.Done():
548 m.agent.mu.Lock()
549 // Delete ourselves from the subscribers list
550 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
551 return x == m.ch
552 })
553 m.subscribed = false
554 m.agent.mu.Unlock()
555 return nil
556 case msg, ok := <-m.ch:
557 if !ok {
558 // Close may have been called
559 return nil
560 }
561 if msg.Idx == m.nextMessageIdx {
562 m.nextMessageIdx++
563 return msg
564 }
565 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
566 panic("out of order message")
567 }
568 }
569}
570
Sean McCulloughd9d45812025-04-30 16:53:41 -0700571// Assert that Agent satisfies the CodingAgent interface.
572var _ CodingAgent = &Agent{}
573
574// StateName implements CodingAgent.
575func (a *Agent) CurrentStateName() string {
576 if a.stateMachine == nil {
577 return ""
578 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000579 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700580}
581
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700582// CurrentTodoContent returns the current todo list data as JSON.
583// It returns an empty string if no todos exist.
584func (a *Agent) CurrentTodoContent() string {
585 todoPath := claudetool.TodoFilePath(a.config.SessionID)
586 content, err := os.ReadFile(todoPath)
587 if err != nil {
588 return ""
589 }
590 return string(content)
591}
592
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700593// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
594func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
595 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.
596
597IMPORTANT: 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.
598
599Please create a detailed summary that includes:
600
6011. **User's Request**: What did the user originally ask me to do? What was their goal?
602
6032. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
604
6053. **Key Technical Decisions**: What important technical choices were made during our work and why?
606
6074. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
608
6095. **Next Steps**: What still needs to be done to complete the user's request?
610
6116. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
612
613Focus 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.
614
615Reply with ONLY the summary content - no meta-commentary about creating the summary.`
616
617 userMessage := llm.UserStringMessage(msg)
618 // Use a subconversation with history to get the summary
619 // TODO: We don't have any tools here, so we should have enough tokens
620 // to capture a summary, but we may need to modify the history (e.g., remove
621 // TODO data) to save on some tokens.
622 convo := a.convo.SubConvoWithHistory()
623
624 // Modify the system prompt to provide context about the original task
625 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000626 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 -0700627
628Your 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.
629
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000630Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700631
632 resp, err := convo.SendMessage(userMessage)
633 if err != nil {
634 a.pushToOutbox(ctx, errorMessage(err))
635 return "", err
636 }
637 textContent := collectTextContent(resp)
638
639 // Restore original system prompt (though this subconvo will be discarded)
640 convo.SystemPrompt = originalSystemPrompt
641
642 return textContent, nil
643}
644
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000645// dumpMessageHistoryToTmp dumps the agent's entire message history to /tmp as JSON
646// and returns the filename
647func (a *Agent) dumpMessageHistoryToTmp(ctx context.Context) (string, error) {
648 // Create a filename based on session ID and timestamp
649 timestamp := time.Now().Format("20060102-150405")
650 filename := fmt.Sprintf("/tmp/sketch-messages-%s-%s.json", a.config.SessionID, timestamp)
651
652 // Marshal the entire message history to JSON
653 jsonData, err := json.MarshalIndent(a.history, "", " ")
654 if err != nil {
655 return "", fmt.Errorf("failed to marshal message history: %w", err)
656 }
657
658 // Write to file
Autoformatter3ad8c8d2025-07-15 21:05:23 +0000659 if err := os.WriteFile(filename, jsonData, 0o644); err != nil {
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000660 return "", fmt.Errorf("failed to write message history to %s: %w", filename, err)
661 }
662
663 slog.InfoContext(ctx, "Dumped message history to file", "filename", filename, "message_count", len(a.history))
664 return filename, nil
665}
666
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700667// CompactConversation compacts the current conversation by generating a summary
668// and restarting the conversation with that summary as the initial context
669func (a *Agent) CompactConversation(ctx context.Context) error {
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000670 // Dump the entire message history to /tmp as JSON before compacting
671 dumpFile, err := a.dumpMessageHistoryToTmp(ctx)
672 if err != nil {
673 slog.WarnContext(ctx, "Failed to dump message history to /tmp", "error", err)
674 // Continue with compaction even if dump fails
675 }
676
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700677 summary, err := a.generateConversationSummary(ctx)
678 if err != nil {
679 return fmt.Errorf("failed to generate conversation summary: %w", err)
680 }
681
682 a.mu.Lock()
683
684 // Get usage information before resetting conversation
685 lastUsage := a.convo.LastUsage()
686 contextWindow := a.config.Service.TokenContextWindow()
687 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
688
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000689 // Preserve cumulative usage across compaction
690 cumulativeUsage := a.convo.CumulativeUsage()
691
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700692 // Reset conversation state but keep all other state (git, working dir, etc.)
693 a.firstMessageIndex = len(a.history)
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000694 a.convo = a.initConvoWithUsage(&cumulativeUsage)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700695
696 a.mu.Unlock()
697
698 // Create informative compaction message with token details
699 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
700 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
701 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
702
703 a.pushToOutbox(ctx, AgentMessage{
704 Type: CompactMessageType,
705 Content: compactionMsg,
706 })
707
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000708 // Create the message content with dump file information if available
709 var messageContent string
710 if dumpFile != "" {
711 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)
712 } else {
713 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)
714 }
715
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700716 a.pushToOutbox(ctx, AgentMessage{
717 Type: UserMessageType,
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000718 Content: messageContent,
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700719 })
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000720 a.inbox <- messageContent
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700721
722 return nil
723}
724
Earl Lee2e463fb2025-04-17 11:22:22 -0700725func (a *Agent) URL() string { return a.url }
726
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000727// GetPorts returns the cached list of open TCP ports.
728func (a *Agent) GetPorts() []portlist.Port {
729 if a.portMonitor == nil {
730 return nil
731 }
732 return a.portMonitor.GetPorts()
733}
734
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000735// BranchName returns the git branch name for the conversation.
736func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700737 return a.gitState.BranchName(a.config.BranchPrefix)
738}
739
740// Slug returns the slug identifier for this conversation.
741func (a *Agent) Slug() string {
742 return a.gitState.Slug()
743}
744
745// IncrementRetryNumber increments the retry number for branch naming conflicts
746func (a *Agent) IncrementRetryNumber() {
747 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000748}
749
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000750// OutstandingLLMCallCount returns the number of outstanding LLM calls.
751func (a *Agent) OutstandingLLMCallCount() int {
752 a.mu.Lock()
753 defer a.mu.Unlock()
754 return len(a.outstandingLLMCalls)
755}
756
757// OutstandingToolCalls returns the names of outstanding tool calls.
758func (a *Agent) OutstandingToolCalls() []string {
759 a.mu.Lock()
760 defer a.mu.Unlock()
761
762 tools := make([]string, 0, len(a.outstandingToolCalls))
763 for _, toolName := range a.outstandingToolCalls {
764 tools = append(tools, toolName)
765 }
766 return tools
767}
768
Earl Lee2e463fb2025-04-17 11:22:22 -0700769// OS returns the operating system of the client.
770func (a *Agent) OS() string {
771 return a.config.ClientGOOS
772}
773
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000774func (a *Agent) SessionID() string {
775 return a.config.SessionID
776}
777
philip.zeyliger8773e682025-06-11 21:36:21 -0700778// SSHConnectionString returns the SSH connection string for the container.
779func (a *Agent) SSHConnectionString() string {
780 return a.config.SSHConnectionString
781}
782
Philip Zeyliger18532b22025-04-23 21:11:46 +0000783// OutsideOS returns the operating system of the outside system.
784func (a *Agent) OutsideOS() string {
785 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000786}
787
Philip Zeyliger18532b22025-04-23 21:11:46 +0000788// OutsideHostname returns the hostname of the outside system.
789func (a *Agent) OutsideHostname() string {
790 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000791}
792
Philip Zeyliger18532b22025-04-23 21:11:46 +0000793// OutsideWorkingDir returns the working directory on the outside system.
794func (a *Agent) OutsideWorkingDir() string {
795 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000796}
797
798// GitOrigin returns the URL of the git remote 'origin' if it exists.
799func (a *Agent) GitOrigin() string {
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +0000800 return a.config.OriginalGitOrigin
Philip Zeyligerd1402952025-04-23 03:54:37 +0000801}
802
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700803// PassthroughUpstream returns whether passthrough upstream is enabled.
804func (a *Agent) PassthroughUpstream() bool {
805 return a.config.PassthroughUpstream
806}
807
bankseancad67b02025-06-27 21:57:05 +0000808// GitUsername returns the git user name from the agent config.
809func (a *Agent) GitUsername() string {
810 return a.config.GitUsername
811}
812
Philip Zeyliger64f60462025-06-16 13:57:10 -0700813// DiffStats returns the number of lines added and removed from sketch-base to HEAD
814func (a *Agent) DiffStats() (int, int) {
815 return a.gitState.DiffStats()
816}
817
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000818func (a *Agent) OpenBrowser(url string) {
819 if !a.IsInContainer() {
820 browser.Open(url)
821 return
822 }
823 // We're in Docker, need to send a request to the Git server
824 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700825 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000826 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700827 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000828 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700829 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000830 return
831 }
832 defer resp.Body.Close()
833 if resp.StatusCode == http.StatusOK {
834 return
835 }
836 body, _ := io.ReadAll(resp.Body)
837 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
838}
839
Sean McCullough96b60dd2025-04-30 09:49:10 -0700840// CurrentState returns the current state of the agent's state machine.
841func (a *Agent) CurrentState() State {
842 return a.stateMachine.CurrentState()
843}
844
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700845func (a *Agent) IsInContainer() bool {
846 return a.config.InDocker
847}
848
849func (a *Agent) FirstMessageIndex() int {
850 a.mu.Lock()
851 defer a.mu.Unlock()
852 return a.firstMessageIndex
853}
854
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700855// SetSlug sets a human-readable identifier for the conversation.
856func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700857 a.mu.Lock()
858 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700859
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700860 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000861 convo, ok := a.convo.(*conversation.Convo)
862 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700863 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000864 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700865}
866
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000867// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700868func (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 +0000869 // Track the tool call
870 a.mu.Lock()
871 a.outstandingToolCalls[id] = toolName
872 a.mu.Unlock()
873}
874
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700875// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
876// If there's only one element in the array and it's a text type, it returns that text directly.
877// It also processes nested ToolResult arrays recursively.
878func contentToString(contents []llm.Content) string {
879 if len(contents) == 0 {
880 return ""
881 }
882
883 // If there's only one element and it's a text type, return it directly
884 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
885 return contents[0].Text
886 }
887
888 // Otherwise, concatenate all text content
889 var result strings.Builder
890 for _, content := range contents {
891 if content.Type == llm.ContentTypeText {
892 result.WriteString(content.Text)
893 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
894 // Recursively process nested tool results
895 result.WriteString(contentToString(content.ToolResult))
896 }
897 }
898
899 return result.String()
900}
901
Earl Lee2e463fb2025-04-17 11:22:22 -0700902// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700903func (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 +0000904 // Remove the tool call from outstanding calls
905 a.mu.Lock()
906 delete(a.outstandingToolCalls, toolID)
907 a.mu.Unlock()
908
Earl Lee2e463fb2025-04-17 11:22:22 -0700909 m := AgentMessage{
910 Type: ToolUseMessageType,
911 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700912 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700913 ToolError: content.ToolError,
914 ToolName: toolName,
915 ToolInput: string(toolInput),
916 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700917 StartTime: content.ToolUseStartTime,
918 EndTime: content.ToolUseEndTime,
Josh Bleecher Snyder3dd3e412025-07-22 20:32:03 -0700919 Display: content.Display,
Earl Lee2e463fb2025-04-17 11:22:22 -0700920 }
921
922 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700923 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
924 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700925 m.Elapsed = &elapsed
926 }
927
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700928 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700929 a.pushToOutbox(ctx, m)
930}
931
932// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700933func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000934 a.mu.Lock()
935 defer a.mu.Unlock()
936 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700937 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
938}
939
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700940// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700941// that need to be displayed (as well as tool calls that we send along when
942// they're done). (It would be reasonable to also mention tool calls when they're
943// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700944func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000945 // Remove the LLM call from outstanding calls
946 a.mu.Lock()
947 delete(a.outstandingLLMCalls, id)
948 a.mu.Unlock()
949
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700950 if resp == nil {
951 // LLM API call failed
952 m := AgentMessage{
953 Type: ErrorMessageType,
954 Content: "API call failed, type 'continue' to try again",
955 }
956 m.SetConvo(convo)
957 a.pushToOutbox(ctx, m)
958 return
959 }
960
Earl Lee2e463fb2025-04-17 11:22:22 -0700961 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700962 if convo.Parent == nil { // subconvos never end the turn
963 switch resp.StopReason {
964 case llm.StopReasonToolUse:
965 // Check whether any of the tool calls are for tools that should end the turn
966 ToolSearch:
967 for _, part := range resp.Content {
968 if part.Type != llm.ContentTypeToolUse {
969 continue
970 }
Sean McCullough021557a2025-05-05 23:20:53 +0000971 // Find the tool by name
972 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700973 if tool.Name == part.ToolName {
974 endOfTurn = tool.EndsTurn
975 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000976 }
977 }
Sean McCullough021557a2025-05-05 23:20:53 +0000978 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700979 default:
980 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000981 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700982 }
983 m := AgentMessage{
984 Type: AgentMessageType,
985 Content: collectTextContent(resp),
986 EndOfTurn: endOfTurn,
987 Usage: &resp.Usage,
988 StartTime: resp.StartTime,
989 EndTime: resp.EndTime,
990 }
991
992 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700993 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700994 var toolCalls []ToolCall
995 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700996 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700997 toolCalls = append(toolCalls, ToolCall{
998 Name: part.ToolName,
999 Input: string(part.ToolInput),
1000 ToolCallId: part.ID,
1001 })
1002 }
1003 }
1004 m.ToolCalls = toolCalls
1005 }
1006
1007 // Calculate the elapsed time if both start and end times are set
1008 if resp.StartTime != nil && resp.EndTime != nil {
1009 elapsed := resp.EndTime.Sub(*resp.StartTime)
1010 m.Elapsed = &elapsed
1011 }
1012
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -07001013 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -07001014 a.pushToOutbox(ctx, m)
1015}
1016
1017// WorkingDir implements CodingAgent.
1018func (a *Agent) WorkingDir() string {
1019 return a.workingDir
1020}
1021
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001022// RepoRoot returns the git repository root directory.
1023func (a *Agent) RepoRoot() string {
1024 return a.repoRoot
1025}
1026
Earl Lee2e463fb2025-04-17 11:22:22 -07001027// MessageCount implements CodingAgent.
1028func (a *Agent) MessageCount() int {
1029 a.mu.Lock()
1030 defer a.mu.Unlock()
1031 return len(a.history)
1032}
1033
1034// Messages implements CodingAgent.
1035func (a *Agent) Messages(start int, end int) []AgentMessage {
1036 a.mu.Lock()
1037 defer a.mu.Unlock()
1038 return slices.Clone(a.history[start:end])
1039}
1040
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001041// ShouldCompact checks if the conversation should be compacted based on token usage
1042func (a *Agent) ShouldCompact() bool {
1043 // Get the threshold from environment variable, default to 0.94 (94%)
1044 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
1045 // and a little bit of buffer.)
1046 thresholdRatio := 0.94
1047 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
1048 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
1049 thresholdRatio = parsed
1050 }
1051 }
1052
1053 // Get the most recent usage to check current context size
1054 lastUsage := a.convo.LastUsage()
1055
1056 if lastUsage.InputTokens == 0 {
1057 // No API calls made yet
1058 return false
1059 }
1060
1061 // Calculate the current context size from the last API call
1062 // This includes all tokens that were part of the input context:
1063 // - Input tokens (user messages, system prompt, conversation history)
1064 // - Cache read tokens (cached parts of the context)
1065 // - Cache creation tokens (new parts being cached)
1066 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
1067
1068 // Get the service's token context window
1069 service := a.config.Service
1070 contextWindow := service.TokenContextWindow()
1071
1072 // Calculate threshold
1073 threshold := uint64(float64(contextWindow) * thresholdRatio)
1074
1075 // Check if we've exceeded the threshold
1076 return currentContextSize >= threshold
1077}
1078
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001079func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -07001080 return a.originalBudget
1081}
1082
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001083// Upstream returns the upstream branch for git work
1084func (a *Agent) Upstream() string {
1085 return a.gitState.Upstream()
1086}
1087
Earl Lee2e463fb2025-04-17 11:22:22 -07001088// AgentConfig contains configuration for creating a new Agent.
1089type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001090 Context context.Context
1091 Service llm.Service
1092 Budget conversation.Budget
1093 GitUsername string
1094 GitEmail string
1095 SessionID string
1096 ClientGOOS string
1097 ClientGOARCH string
1098 InDocker bool
1099 OneShot bool
1100 WorkingDir string
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +00001101 // Model is the name of the LLM model being used
1102 Model string
Philip Zeyliger18532b22025-04-23 21:11:46 +00001103 // Outside information
1104 OutsideHostname string
1105 OutsideOS string
1106 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001107
1108 // Outtie's HTTP to, e.g., open a browser
1109 OutsideHTTP string
1110 // Outtie's Git server
1111 GitRemoteAddr string
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001112 // Original git origin URL from host repository, if any
1113 OriginalGitOrigin string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001114 // Upstream branch for git work
1115 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001116 // Commit to checkout from Outtie
1117 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001118 // Prefix for git branches created by sketch
1119 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001120 // LinkToGitHub enables GitHub branch linking in UI
1121 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001122 // SSH connection string for connecting to the container
1123 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001124 // Skaband client for session history (optional)
1125 SkabandClient *skabandclient.SkabandClient
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001126 // MCP server configurations
1127 MCPServers []string
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001128 // Timeout configuration for bash tool
1129 BashTimeouts *claudetool.Timeouts
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001130 // PassthroughUpstream configures upstream remote for passthrough to innie
1131 PassthroughUpstream bool
Earl Lee2e463fb2025-04-17 11:22:22 -07001132}
1133
1134// NewAgent creates a new Agent.
1135// It is not usable until Init() is called.
1136func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001137 // Set default branch prefix if not specified
1138 if config.BranchPrefix == "" {
1139 config.BranchPrefix = "sketch/"
1140 }
1141
Earl Lee2e463fb2025-04-17 11:22:22 -07001142 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001143 config: config,
1144 ready: make(chan struct{}),
1145 inbox: make(chan string, 100),
1146 subscribers: make([]chan *AgentMessage, 0),
1147 startedAt: time.Now(),
1148 originalBudget: config.Budget,
1149 gitState: AgentGitState{
1150 seenCommits: make(map[string]bool),
1151 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001152 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001153 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001154 outsideHostname: config.OutsideHostname,
1155 outsideOS: config.OutsideOS,
1156 outsideWorkingDir: config.OutsideWorkingDir,
1157 outstandingLLMCalls: make(map[string]struct{}),
1158 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001159 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001160 workingDir: config.WorkingDir,
1161 outsideHTTP: config.OutsideHTTP,
Philip Zeyligerda623b52025-07-04 01:12:38 +00001162
1163 mcpManager: mcp.NewMCPManager(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001164 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001165
1166 // Initialize port monitor with 5-second interval
1167 agent.portMonitor = NewPortMonitor(agent, 5*time.Second)
1168
Earl Lee2e463fb2025-04-17 11:22:22 -07001169 return agent
1170}
1171
1172type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001173 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001174
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001175 InDocker bool
1176 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001177}
1178
1179func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001180 if a.convo != nil {
1181 return fmt.Errorf("Agent.Init: already initialized")
1182 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001183 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001184 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001185
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001186 // If a remote + commit was specified, clone it.
1187 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001188 if _, err := os.Stat("/app/.git"); err != nil {
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00001189 slog.InfoContext(ctx, "cloning git repo", "commit", a.config.Commit)
1190 // TODO: --reference-if-able instead?
1191 cmd := exec.CommandContext(ctx, "git", "clone", "--reference", "/git-ref", a.gitState.gitRemoteAddr, "/app")
1192 if out, err := cmd.CombinedOutput(); err != nil {
1193 return fmt.Errorf("failed to clone repository from %s: %s: %w", a.gitState.gitRemoteAddr, out, err)
1194 }
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001195 }
1196 }
1197
1198 if a.workingDir != "" {
1199 err := os.Chdir(a.workingDir)
1200 if err != nil {
1201 return fmt.Errorf("failed to change working directory to %s: %w", a.workingDir, err)
1202 }
1203 }
1204
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001205 if !ini.NoGit {
Philip Zeyligeraccf37c2025-07-18 07:29:19 -07001206 if a.gitState.gitRemoteAddr != "" {
1207 if err := upsertRemoteOrigin(ctx, "/app", a.gitState.gitRemoteAddr); err != nil {
1208 return err
1209 }
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001210 }
Philip Zeyligere1c8b7b2025-07-03 14:50:26 -07001211
1212 // Configure git user settings
1213 if a.config.GitEmail != "" {
1214 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.email", a.config.GitEmail)
1215 cmd.Dir = a.workingDir
1216 if out, err := cmd.CombinedOutput(); err != nil {
1217 return fmt.Errorf("git config --global user.email: %s: %v", out, err)
1218 }
1219 }
1220 if a.config.GitUsername != "" {
1221 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.name", a.config.GitUsername)
1222 cmd.Dir = a.workingDir
1223 if out, err := cmd.CombinedOutput(); err != nil {
1224 return fmt.Errorf("git config --global user.name: %s: %v", out, err)
1225 }
1226 }
1227 // Configure git http.postBuffer
1228 cmd := exec.CommandContext(ctx, "git", "config", "--global", "http.postBuffer", "524288000")
1229 cmd.Dir = a.workingDir
1230 if out, err := cmd.CombinedOutput(); err != nil {
1231 return fmt.Errorf("git config --global http.postBuffer: %s: %v", out, err)
1232 }
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001233
1234 // Configure passthrough upstream if enabled
1235 if a.config.PassthroughUpstream {
1236 if err := a.configurePassthroughUpstream(ctx); err != nil {
1237 return fmt.Errorf("failed to configure passthrough upstream: %w", err)
1238 }
1239 }
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001240 }
1241
Philip Zeyligerf2872992025-05-22 10:35:28 -07001242 // If a commit was specified, we fetch and reset to it.
1243 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001244 slog.InfoContext(ctx, "updating git repo", "commit", a.config.Commit)
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001245
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001246 cmd := exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001247 cmd.Dir = a.workingDir
Earl Lee2e463fb2025-04-17 11:22:22 -07001248 if out, err := cmd.CombinedOutput(); err != nil {
1249 return fmt.Errorf("git fetch: %s: %w", out, err)
1250 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001251 // The -B resets the branch if it already exists (or creates it if it doesn't)
1252 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001253 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001254 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1255 // Remove git hooks if they exist and retry
1256 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001257 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001258 if _, statErr := os.Stat(hookPath); statErr == nil {
1259 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1260 slog.String("error", err.Error()),
1261 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001262 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001263 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1264 }
1265
1266 // Retry the checkout operation
Philip Zeyliger1417b692025-06-12 11:07:04 -07001267 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001268 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001269 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001270 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 +01001271 }
1272 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001273 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001274 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001275 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001276 } else if a.IsInContainer() {
1277 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1278 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1279 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1280 cmd.Dir = a.workingDir
1281 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1282 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1283 }
1284 } else {
1285 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001286 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001287
1288 if ini.HostAddr != "" {
1289 a.url = "http://" + ini.HostAddr
1290 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001291
1292 if !ini.NoGit {
1293 repoRoot, err := repoRoot(ctx, a.workingDir)
1294 if err != nil {
1295 return fmt.Errorf("repoRoot: %w", err)
1296 }
1297 a.repoRoot = repoRoot
1298
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001299 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001300 if err := setupGitHooks(a.repoRoot); err != nil {
1301 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1302 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001303 }
1304
philz24613202025-07-15 20:56:21 -07001305 // Check if we have any commits, and if not, create an empty initial commit
1306 cmd := exec.CommandContext(ctx, "git", "rev-list", "--all", "--count")
1307 cmd.Dir = repoRoot
1308 countOut, err := cmd.CombinedOutput()
1309 if err != nil {
1310 return fmt.Errorf("git rev-list --all --count: %s: %w", countOut, err)
1311 }
1312 commitCount := strings.TrimSpace(string(countOut))
1313 if commitCount == "0" {
1314 slog.Info("No commits found, creating empty initial commit")
1315 cmd = exec.CommandContext(ctx, "git", "commit", "--allow-empty", "-m", "Initial empty commit")
1316 cmd.Dir = repoRoot
1317 if commitOut, err := cmd.CombinedOutput(); err != nil {
1318 return fmt.Errorf("git commit --allow-empty: %s: %w", commitOut, err)
1319 }
1320 }
1321
1322 cmd = exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
Philip Zeyliger49edc922025-05-14 09:45:45 -07001323 cmd.Dir = repoRoot
1324 if out, err := cmd.CombinedOutput(); err != nil {
1325 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1326 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001327
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001328 slog.Info("running codebase analysis")
1329 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1330 if err != nil {
1331 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001332 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001333 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001334
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001335 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001336 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001337 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001338 }
1339 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001340
Earl Lee2e463fb2025-04-17 11:22:22 -07001341 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001342 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001343 a.convo = a.initConvo()
1344 close(a.ready)
1345 return nil
1346}
1347
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001348//go:embed agent_system_prompt.txt
1349var agentSystemPrompt string
1350
Earl Lee2e463fb2025-04-17 11:22:22 -07001351// initConvo initializes the conversation.
1352// It must not be called until all agent fields are initialized,
1353// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001354func (a *Agent) initConvo() *conversation.Convo {
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001355 return a.initConvoWithUsage(nil)
1356}
1357
1358// initConvoWithUsage initializes the conversation with optional preserved usage.
1359func (a *Agent) initConvoWithUsage(usage *conversation.CumulativeUsage) *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001360 ctx := a.config.Context
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001361 convo := conversation.New(ctx, a.config.Service, usage)
Earl Lee2e463fb2025-04-17 11:22:22 -07001362 convo.PromptCaching = true
1363 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001364 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001365 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001366
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001367 bashTool := &claudetool.BashTool{
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001368 EnableJITInstall: claudetool.EnableBashToolJITInstall,
1369 Timeouts: a.config.BashTimeouts,
1370 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001371
Earl Lee2e463fb2025-04-17 11:22:22 -07001372 // Register all tools with the conversation
1373 // When adding, removing, or modifying tools here, double-check that the termui tool display
1374 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001375
1376 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001377 _, supportsScreenshots := a.config.Service.(*ant.Service)
1378 var bTools []*llm.Tool
1379 var browserCleanup func()
1380
1381 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1382 // Add cleanup function to context cancel
1383 go func() {
1384 <-a.config.Context.Done()
1385 browserCleanup()
1386 }()
1387 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001388
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001389 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd64bc912025-07-24 11:42:33 -07001390 bashTool.Tool(),
1391 claudetool.Keyword,
1392 claudetool.Patch(a.patchCallback),
1393 claudetool.Think,
1394 claudetool.TodoRead,
1395 claudetool.TodoWrite,
1396 makeDoneTool(a.codereview),
1397 a.codereview.Tool(),
1398 claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001399 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001400 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001401
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001402 // Add MCP tools if configured
1403 if len(a.config.MCPServers) > 0 {
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001404
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001405 slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers))
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001406 serverConfigs, parseErrors := mcp.ParseServerConfigs(ctx, a.config.MCPServers)
1407
1408 // Replace any headers with value _sketch_public_key_ and _sketch_session_id_ with those values.
1409 for i := range serverConfigs {
1410 if serverConfigs[i].Headers != nil {
1411 for key, value := range serverConfigs[i].Headers {
Philip Zeyligerf2814ea2025-06-30 10:16:50 -07001412 // Replace env placeholders. E.g., "env:FOO" becomes os.Getenv("FOO")
1413 if strings.HasPrefix(value, "env:") {
1414 serverConfigs[i].Headers[key] = os.Getenv(value[4:])
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001415 }
1416 }
1417 }
1418 }
Philip Zeyligerc540df72025-07-25 09:21:56 -07001419 mcpConnections, mcpErrors := a.mcpManager.ConnectToServerConfigs(ctx, serverConfigs, mcp.DefaultMCPConnectionTimeout, parseErrors)
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001420
1421 if len(mcpErrors) > 0 {
1422 for _, err := range mcpErrors {
1423 slog.ErrorContext(ctx, "MCP connection error", "error", err)
1424 // Send agent message about MCP connection failures
1425 a.pushToOutbox(ctx, AgentMessage{
1426 Type: ErrorMessageType,
1427 Content: fmt.Sprintf("MCP server connection failed: %v", err),
1428 })
1429 }
1430 }
1431
1432 if len(mcpConnections) > 0 {
1433 // Add tools from all successful connections
1434 totalTools := 0
1435 for _, connection := range mcpConnections {
1436 convo.Tools = append(convo.Tools, connection.Tools...)
1437 totalTools += len(connection.Tools)
1438 // Log tools per server using structured data
1439 slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames)
1440 }
1441 slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools)
1442 } else {
1443 slog.InfoContext(ctx, "No MCP tools available after connection attempts")
1444 }
1445 }
1446
Earl Lee2e463fb2025-04-17 11:22:22 -07001447 convo.Listener = a
1448 return convo
1449}
1450
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001451// branchExists reports whether branchName exists, either locally or in well-known remotes.
1452func branchExists(dir, branchName string) bool {
1453 refs := []string{
1454 "refs/heads/",
1455 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001456 }
1457 for _, ref := range refs {
1458 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1459 cmd.Dir = dir
1460 if cmd.Run() == nil { // exit code 0 means branch exists
1461 return true
1462 }
1463 }
1464 return false
1465}
1466
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001467func soleText(contents []llm.Content) (string, error) {
1468 if len(contents) != 1 {
1469 return "", fmt.Errorf("multiple contents %v", contents)
1470 }
1471 content := contents[0]
1472 if content.Type != llm.ContentTypeText || content.Text == "" {
1473 return "", fmt.Errorf("bad content %v", content)
1474 }
1475 return strings.TrimSpace(content.Text), nil
1476}
1477
1478// autoGenerateSlug automatically generates a slug based on the first user input
1479func (a *Agent) autoGenerateSlug(ctx context.Context, userContents []llm.Content) error {
1480 userText, err := soleText(userContents)
1481 if err != nil {
1482 return err
1483 }
1484 if userText == "" {
1485 return fmt.Errorf("set-slug: empty text content")
1486 }
1487
1488 // Create a subconversation without history for slug generation
1489 convo, ok := a.convo.(*conversation.Convo)
1490 if !ok {
1491 // In test environments, the conversation might be a mock interface
1492 // Skip slug generation in this case
1493 return fmt.Errorf("set-slug: can't make a subconvo (mock convo?)")
1494 }
1495
1496 // Loop until we find an acceptable slug
1497 var unavailableSlugs []string
1498 for {
1499 if len(unavailableSlugs) > 10 {
1500 // sanity check to prevent infinite loops
1501 return fmt.Errorf("set-slug: failed to construct a new slug after %d attempts", len(unavailableSlugs))
Earl Lee2e463fb2025-04-17 11:22:22 -07001502 }
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001503 subConvo := convo.SubConvo()
1504 subConvo.Hidden = true
1505
1506 // Prompt for slug generation
1507 prompt := `You are a slug generator for Sketch, an agentic coding environment.
1508The user's prompt will be in <user-prompt> tags. Any unavailable slugs will be listed in <unavailable-slug> tags.
1509Generate a 2-3 word alphanumeric hyphenated slug in imperative tense that captures the essence of their coding task.
1510Respond with only the slug.`
1511
1512 buf := new(strings.Builder)
1513 buf.WriteString("<slug-request>")
1514 if len(unavailableSlugs) > 0 {
1515 buf.WriteString("<unavailable-slugs>")
1516 }
1517 for _, x := range unavailableSlugs {
1518 buf.WriteString("<unavailable-slug>")
1519 buf.WriteString(x)
1520 buf.WriteString("</unavailable-slug>")
1521 }
1522 if len(unavailableSlugs) > 0 {
1523 buf.WriteString("</unavailable-slugs>")
1524 }
1525 buf.WriteString("<user-prompt>")
1526 buf.WriteString(userText)
1527 buf.WriteString("</user-prompt>")
1528 buf.WriteString("</slug-request>")
1529
1530 fullPrompt := prompt + "\n" + buf.String()
1531 userMessage := llm.UserStringMessage(fullPrompt)
1532
1533 resp, err := subConvo.SendMessage(userMessage)
1534 if err != nil {
1535 return fmt.Errorf("failed to generate slug: %w", err)
1536 }
1537
1538 // Extract the slug from the response
1539 slugText, err := soleText(resp.Content)
1540 if err != nil {
1541 return err
1542 }
1543 if slugText == "" {
1544 return fmt.Errorf("empty slug generated")
1545 }
1546
1547 // Clean and validate the slug
1548 slug := cleanSlugName(slugText)
1549 if slug == "" {
1550 return fmt.Errorf("slug could not be cleaned: %q", slugText)
1551 }
1552
1553 // Check if branch already exists using the same logic as the original set-slug tool
1554 a.SetSlug(slug) // Set slug first so BranchName() works correctly
1555 if branchExists(a.workingDir, a.BranchName()) {
1556 // try again
1557 unavailableSlugs = append(unavailableSlugs, slug)
1558 continue
1559 }
1560
1561 // Success! Slug is available and already set
1562 return nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001563 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001564}
1565
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001566// patchCallback is the agent's patch tool callback.
1567// It warms the codereview cache in the background.
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001568func (a *Agent) patchCallback(input claudetool.PatchInput, output llm.ToolOut) llm.ToolOut {
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001569 if a.codereview != nil {
1570 a.codereview.WarmTestCache(input.Path)
1571 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001572 return output
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001573}
1574
Earl Lee2e463fb2025-04-17 11:22:22 -07001575func (a *Agent) Ready() <-chan struct{} {
1576 return a.ready
1577}
1578
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001579// BranchPrefix returns the configured branch prefix
1580func (a *Agent) BranchPrefix() string {
1581 return a.config.BranchPrefix
1582}
1583
philip.zeyliger6d3de482025-06-10 19:38:14 -07001584// LinkToGitHub returns whether GitHub branch linking is enabled
1585func (a *Agent) LinkToGitHub() bool {
1586 return a.config.LinkToGitHub
1587}
1588
Earl Lee2e463fb2025-04-17 11:22:22 -07001589func (a *Agent) UserMessage(ctx context.Context, msg string) {
1590 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1591 a.inbox <- msg
1592}
1593
Earl Lee2e463fb2025-04-17 11:22:22 -07001594func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1595 return a.convo.CancelToolUse(toolUseID, cause)
1596}
1597
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001598func (a *Agent) CancelTurn(cause error) {
1599 a.cancelTurnMu.Lock()
1600 defer a.cancelTurnMu.Unlock()
1601 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001602 // Force state transition to cancelled state
1603 ctx := a.config.Context
1604 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001605 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001606 }
1607}
1608
1609func (a *Agent) Loop(ctxOuter context.Context) {
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001610 // Start port monitoring
1611 if a.portMonitor != nil && a.IsInContainer() {
1612 if err := a.portMonitor.Start(ctxOuter); err != nil {
1613 slog.WarnContext(ctxOuter, "Failed to start port monitor", "error", err)
1614 } else {
1615 slog.InfoContext(ctxOuter, "Port monitor started")
1616 }
1617 }
1618
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001619 // Set up cleanup when context is done
1620 defer func() {
1621 if a.mcpManager != nil {
1622 a.mcpManager.Close()
1623 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001624 if a.portMonitor != nil && a.IsInContainer() {
1625 a.portMonitor.Stop()
1626 }
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001627 }()
1628
Earl Lee2e463fb2025-04-17 11:22:22 -07001629 for {
1630 select {
1631 case <-ctxOuter.Done():
1632 return
1633 default:
1634 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001635 a.cancelTurnMu.Lock()
1636 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001637 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001638 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001639 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001640 a.cancelTurn = cancel
1641 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001642 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1643 if err != nil {
1644 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1645 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001646 cancel(nil)
1647 }
1648 }
1649}
1650
1651func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1652 if m.Timestamp.IsZero() {
1653 m.Timestamp = time.Now()
1654 }
1655
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001656 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1657 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1658 m.Content = m.ToolResult
1659 }
1660
Earl Lee2e463fb2025-04-17 11:22:22 -07001661 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1662 if m.EndOfTurn && m.Type == AgentMessageType {
1663 turnDuration := time.Since(a.startOfTurn)
1664 m.TurnDuration = &turnDuration
1665 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1666 }
1667
Earl Lee2e463fb2025-04-17 11:22:22 -07001668 a.mu.Lock()
1669 defer a.mu.Unlock()
1670 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001671 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001672 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001673
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001674 // Notify all subscribers
1675 for _, ch := range a.subscribers {
1676 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001677 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001678}
1679
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001680func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1681 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001682 if block {
1683 select {
1684 case <-ctx.Done():
1685 return m, ctx.Err()
1686 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001687 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001688 }
1689 }
1690 for {
1691 select {
1692 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001693 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001694 default:
1695 return m, nil
1696 }
1697 }
1698}
1699
Sean McCullough885a16a2025-04-30 02:49:25 +00001700// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001701func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001702 // Reset the start of turn time
1703 a.startOfTurn = time.Now()
1704
Sean McCullough96b60dd2025-04-30 09:49:10 -07001705 // Transition to waiting for user input state
1706 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1707
Sean McCullough885a16a2025-04-30 02:49:25 +00001708 // Process initial user message
1709 initialResp, err := a.processUserMessage(ctx)
1710 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001711 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001712 return err
1713 }
1714
1715 // Handle edge case where both initialResp and err are nil
1716 if initialResp == nil {
1717 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001718 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1719
Sean McCullough9f4b8082025-04-30 17:34:07 +00001720 a.pushToOutbox(ctx, errorMessage(err))
1721 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001722 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001723
Earl Lee2e463fb2025-04-17 11:22:22 -07001724 // We do this as we go, but let's also do it at the end of the turn
1725 defer func() {
1726 if _, err := a.handleGitCommits(ctx); err != nil {
1727 // Just log the error, don't stop execution
1728 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1729 }
1730 }()
1731
Sean McCullougha1e0e492025-05-01 10:51:08 -07001732 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001733 resp := initialResp
1734 for {
1735 // Check if we are over budget
1736 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001737 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001738 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001739 }
1740
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001741 // Check if we should compact the conversation
1742 if a.ShouldCompact() {
1743 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1744 if err := a.CompactConversation(ctx); err != nil {
1745 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1746 return err
1747 }
1748 // After compaction, end this turn and start fresh
1749 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1750 return nil
1751 }
1752
Sean McCullough885a16a2025-04-30 02:49:25 +00001753 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001754 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001755 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001756 break
1757 }
1758
Sean McCullough96b60dd2025-04-30 09:49:10 -07001759 // Transition to tool use requested state
1760 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1761
Sean McCullough885a16a2025-04-30 02:49:25 +00001762 // Handle tool execution
1763 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1764 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001765 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001766 }
1767
Sean McCullougha1e0e492025-05-01 10:51:08 -07001768 if toolResp == nil {
1769 return fmt.Errorf("cannot continue conversation with a nil tool response")
1770 }
1771
Sean McCullough885a16a2025-04-30 02:49:25 +00001772 // Set the response for the next iteration
1773 resp = toolResp
1774 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001775
1776 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001777}
1778
1779// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001780func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001781 // Wait for at least one message from the user
1782 msgs, err := a.GatherMessages(ctx, true)
1783 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001784 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001785 return nil, err
1786 }
1787
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001788 // Auto-generate slug if this is the first user input and no slug is set
1789 if a.Slug() == "" {
1790 if err := a.autoGenerateSlug(ctx, msgs); err != nil {
1791 // NB: it is possible that autoGenerateSlug set the slug during the process
1792 // of trying to generate a slug.
1793 // The fact that it returned an error means that we cannot use that slug.
1794 slog.WarnContext(ctx, "Failed to auto-generate slug", "error", err)
1795 // use the session id instead. ugly, but we need a slug, and this will be unique.
1796 a.SetSlug(a.SessionID())
1797 }
1798 // Notify termui of the final slug (only emitted once, after slug is determined)
1799 a.pushToOutbox(ctx, AgentMessage{
1800 Type: SlugMessageType,
1801 Content: a.Slug(),
1802 })
1803 }
1804
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001805 userMessage := llm.Message{
1806 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001807 Content: msgs,
1808 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001809
Sean McCullough96b60dd2025-04-30 09:49:10 -07001810 // Transition to sending to LLM state
1811 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1812
Sean McCullough885a16a2025-04-30 02:49:25 +00001813 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001814 resp, err := a.convo.SendMessage(userMessage)
1815 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001816 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001817 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001818 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001819 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001820
Sean McCullough96b60dd2025-04-30 09:49:10 -07001821 // Transition to processing LLM response state
1822 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1823
Sean McCullough885a16a2025-04-30 02:49:25 +00001824 return resp, nil
1825}
1826
1827// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001828func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1829 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001830 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001831 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001832
Sean McCullough96b60dd2025-04-30 09:49:10 -07001833 // Transition to checking for cancellation state
1834 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1835
Sean McCullough885a16a2025-04-30 02:49:25 +00001836 // Check if the operation was cancelled by the user
1837 select {
1838 case <-ctx.Done():
1839 // Don't actually run any of the tools, but rather build a response
1840 // for each tool_use message letting the LLM know that user canceled it.
1841 var err error
1842 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001843 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001844 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001845 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001846 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001847 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001848 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001849 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001850 // Transition to running tool state
1851 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1852
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001853 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001854 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001855 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001856
1857 // Execute the tools
1858 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001859 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001860 if ctx.Err() != nil { // e.g. the user canceled the operation
1861 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001862 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001863 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001864 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001865 a.pushToOutbox(ctx, errorMessage(err))
1866 }
1867 }
1868
1869 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001870 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001871 autoqualityMessages := a.processGitChanges(ctx)
1872
1873 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001874 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001875 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001876 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001877 return false, nil
1878 }
1879
1880 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001881 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1882 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001883}
1884
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001885// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001886func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001887 // Check for git commits
1888 _, err := a.handleGitCommits(ctx)
1889 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001890 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001891 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001892 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001893 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001894}
1895
1896// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1897// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001898func (a *Agent) processGitChanges(ctx context.Context) []string {
1899 // Check for git commits after tool execution
1900 newCommits, err := a.handleGitCommits(ctx)
1901 if err != nil {
1902 // Just log the error, don't stop execution
1903 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1904 return nil
1905 }
1906
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001907 // Run mechanical checks if there was exactly one new commit.
1908 if len(newCommits) != 1 {
1909 return nil
1910 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001911 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001912 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1913 msg := a.codereview.RunMechanicalChecks(ctx)
1914 if msg != "" {
1915 a.pushToOutbox(ctx, AgentMessage{
1916 Type: AutoMessageType,
1917 Content: msg,
1918 Timestamp: time.Now(),
1919 })
1920 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001921 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001922
1923 return autoqualityMessages
1924}
1925
1926// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001927func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001928 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001929 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001930 msgs, err := a.GatherMessages(ctx, false)
1931 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001932 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001933 return false, nil
1934 }
1935
1936 // Inject any auto-generated messages from quality checks
1937 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001938 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001939 }
1940
1941 // Handle cancellation by appending a message about it
1942 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001943 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001944 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001945 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001946 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1947 } else if err := a.convo.OverBudget(); err != nil {
1948 // Handle budget issues by appending a message about it
1949 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 -07001950 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001951 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1952 }
1953
1954 // Combine tool results with user messages
1955 results = append(results, msgs...)
1956
1957 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001958 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001959 resp, err := a.convo.SendMessage(llm.Message{
1960 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001961 Content: results,
1962 })
1963 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001964 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001965 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1966 return true, nil // Return true to continue the conversation, but with no response
1967 }
1968
Sean McCullough96b60dd2025-04-30 09:49:10 -07001969 // Transition back to processing LLM response
1970 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1971
Sean McCullough885a16a2025-04-30 02:49:25 +00001972 if cancelled {
1973 return false, nil
1974 }
1975
1976 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001977}
1978
1979func (a *Agent) overBudget(ctx context.Context) error {
1980 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001981 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001982 m := budgetMessage(err)
1983 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001984 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001985 a.convo.ResetBudget(a.originalBudget)
1986 return err
1987 }
1988 return nil
1989}
1990
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001991func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001992 // Collect all text content
1993 var allText strings.Builder
1994 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001995 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07001996 if allText.Len() > 0 {
1997 allText.WriteString("\n\n")
1998 }
1999 allText.WriteString(content.Text)
2000 }
2001 }
2002 return allText.String()
2003}
2004
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002005func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07002006 a.mu.Lock()
2007 defer a.mu.Unlock()
2008 return a.convo.CumulativeUsage()
2009}
2010
Earl Lee2e463fb2025-04-17 11:22:22 -07002011// Diff returns a unified diff of changes made since the agent was instantiated.
2012func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07002013 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07002014 return "", fmt.Errorf("no initial commit reference available")
2015 }
2016
2017 // Find the repository root
2018 ctx := context.Background()
2019
2020 // If a specific commit hash is provided, show just that commit's changes
2021 if commit != nil && *commit != "" {
2022 // Validate that the commit looks like a valid git SHA
2023 if !isValidGitSHA(*commit) {
2024 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
2025 }
2026
2027 // Get the diff for just this commit
2028 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
2029 cmd.Dir = a.repoRoot
2030 output, err := cmd.CombinedOutput()
2031 if err != nil {
2032 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
2033 }
2034 return string(output), nil
2035 }
2036
2037 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07002038 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07002039 cmd.Dir = a.repoRoot
2040 output, err := cmd.CombinedOutput()
2041 if err != nil {
2042 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
2043 }
2044
2045 return string(output), nil
2046}
2047
Philip Zeyliger49edc922025-05-14 09:45:45 -07002048// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
2049// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
2050func (a *Agent) SketchGitBaseRef() string {
2051 if a.IsInContainer() {
2052 return "sketch-base"
2053 } else {
2054 return "sketch-base-" + a.SessionID()
2055 }
2056}
2057
2058// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
2059func (a *Agent) SketchGitBase() string {
2060 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
2061 cmd.Dir = a.repoRoot
2062 output, err := cmd.CombinedOutput()
2063 if err != nil {
2064 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
2065 return "HEAD"
2066 }
2067 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002068}
2069
Pokey Rule7a113622025-05-12 10:58:45 +01002070// removeGitHooks removes the Git hooks directory from the repository
2071func removeGitHooks(_ context.Context, repoPath string) error {
2072 hooksDir := filepath.Join(repoPath, ".git", "hooks")
2073
2074 // Check if hooks directory exists
2075 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
2076 // Directory doesn't exist, nothing to do
2077 return nil
2078 }
2079
2080 // Remove the hooks directory
2081 err := os.RemoveAll(hooksDir)
2082 if err != nil {
2083 return fmt.Errorf("failed to remove git hooks directory: %w", err)
2084 }
2085
2086 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00002087 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01002088 if err != nil {
2089 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
2090 }
2091
2092 return nil
2093}
2094
Philip Zeyligerf2872992025-05-22 10:35:28 -07002095func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002096 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002097 for _, msg := range msgs {
2098 a.pushToOutbox(ctx, msg)
2099 }
2100 return commits, error
2101}
2102
Earl Lee2e463fb2025-04-17 11:22:22 -07002103// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002104// under docker, new HEADs are pushed to a branch according to the slug.
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002105func (ags *AgentGitState) handleGitCommits(ctx context.Context, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002106 ags.mu.Lock()
2107 defer ags.mu.Unlock()
2108
2109 msgs := []AgentMessage{}
2110 if repoRoot == "" {
2111 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002112 }
2113
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002114 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07002115 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002116 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07002117 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002118 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002119 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07002120 }
2121 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002122 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07002123 }()
2124
Philip Zeyliger64f60462025-06-16 13:57:10 -07002125 // Compute diff stats from baseRef to HEAD when HEAD changes
2126 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
2127 // Log error but don't fail the entire operation
2128 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
2129 } else {
2130 // Set diff stats directly since we already hold the mutex
2131 ags.linesAdded = added
2132 ags.linesRemoved = removed
2133 }
2134
Earl Lee2e463fb2025-04-17 11:22:22 -07002135 // Get new commits. Because it's possible that the agent does rebases, fixups, and
2136 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
2137 // to the last 100 commits.
2138 var commits []*GitCommit
2139
2140 // Get commits since the initial commit
2141 // Format: <hash>\0<subject>\0<body>\0
2142 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
2143 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002144 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 -07002145 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07002146 output, err := cmd.Output()
2147 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002148 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07002149 }
2150
2151 // Parse git log output and filter out already seen commits
2152 parsedCommits := parseGitLog(string(output))
2153
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002154 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07002155
2156 // Filter out commits we've already seen
2157 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002158 if commit.Hash == sketch {
2159 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07002160 }
2161
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002162 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
2163 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07002164 continue
2165 }
2166
2167 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07002168 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07002169
2170 // Add to our list of new commits
2171 commits = append(commits, &commit)
2172 }
2173
Philip Zeyligerf2872992025-05-22 10:35:28 -07002174 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002175 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07002176 // 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 -07002177 sketchCommit = &GitCommit{}
2178 sketchCommit.Hash = sketch
2179 sketchCommit.Subject = "unknown"
2180 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07002181 }
2182
Earl Lee2e463fb2025-04-17 11:22:22 -07002183 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
2184 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
2185 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00002186
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002187 // 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 +00002188 var out []byte
2189 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002190 originalRetryNumber := ags.retryNumber
2191 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00002192 for retries := range 10 {
2193 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07002194 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002195 }
2196
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002197 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002198 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002199 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002200 out, err = cmd.CombinedOutput()
2201
2202 if err == nil {
2203 // Success! Break out of the retry loop
2204 break
2205 }
2206
2207 // Check if this is the "refusing to update checked out branch" error
2208 if !strings.Contains(string(out), "refusing to update checked out branch") {
2209 // This is a different error, so don't retry
2210 break
2211 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002212 }
2213
2214 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002215 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002216 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002217 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002218 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002219 if ags.retryNumber != originalRetryNumber {
2220 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002221 msgs = append(msgs, AgentMessage{
2222 Type: AutoMessageType,
2223 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002224 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 +00002225 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002226 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002227 }
2228 }
2229
2230 // If we found new commits, create a message
2231 if len(commits) > 0 {
2232 msg := AgentMessage{
2233 Type: CommitMessageType,
2234 Timestamp: time.Now(),
2235 Commits: commits,
2236 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002237 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002238 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002239 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002240}
2241
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002242func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002243 return strings.Map(func(r rune) rune {
2244 // lowercase
2245 if r >= 'A' && r <= 'Z' {
2246 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002247 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002248 // replace spaces with dashes
2249 if r == ' ' {
2250 return '-'
2251 }
2252 // allow alphanumerics and dashes
2253 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2254 return r
2255 }
2256 return -1
2257 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002258}
2259
2260// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2261// and returns an array of GitCommit structs.
2262func parseGitLog(output string) []GitCommit {
2263 var commits []GitCommit
2264
2265 // No output means no commits
2266 if len(output) == 0 {
2267 return commits
2268 }
2269
2270 // Split by NULL byte
2271 parts := strings.Split(output, "\x00")
2272
2273 // Process in triplets (hash, subject, body)
2274 for i := 0; i < len(parts); i++ {
2275 // Skip empty parts
2276 if parts[i] == "" {
2277 continue
2278 }
2279
2280 // This should be a hash
2281 hash := strings.TrimSpace(parts[i])
2282
2283 // Make sure we have at least a subject part available
2284 if i+1 >= len(parts) {
2285 break // No more parts available
2286 }
2287
2288 // Get the subject
2289 subject := strings.TrimSpace(parts[i+1])
2290
2291 // Get the body if available
2292 body := ""
2293 if i+2 < len(parts) {
2294 body = strings.TrimSpace(parts[i+2])
2295 }
2296
2297 // Skip to the next triplet
2298 i += 2
2299
2300 commits = append(commits, GitCommit{
2301 Hash: hash,
2302 Subject: subject,
2303 Body: body,
2304 })
2305 }
2306
2307 return commits
2308}
2309
2310func repoRoot(ctx context.Context, dir string) (string, error) {
2311 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2312 stderr := new(strings.Builder)
2313 cmd.Stderr = stderr
2314 cmd.Dir = dir
2315 out, err := cmd.Output()
2316 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002317 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002318 }
2319 return strings.TrimSpace(string(out)), nil
2320}
2321
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002322// upsertRemoteOrigin configures the origin remote to point to the given URL.
2323// If the origin remote exists, it updates the URL. If it doesn't exist, it adds it.
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002324//
2325// NOTE: Maybe we should use an "insteadOf" setting instead of changing the URL.
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002326func upsertRemoteOrigin(ctx context.Context, repoDir, remoteURL string) error {
2327 // Try to set the URL for existing origin remote
2328 cmd := exec.CommandContext(ctx, "git", "remote", "set-url", "origin", remoteURL)
2329 cmd.Dir = repoDir
2330 if _, err := cmd.CombinedOutput(); err == nil {
2331 // Success.
2332 return nil
2333 }
2334 // Origin doesn't exist; add it.
2335 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", remoteURL)
2336 cmd.Dir = repoDir
2337 if out, err := cmd.CombinedOutput(); err != nil {
2338 return fmt.Errorf("failed to add git remote origin: %s: %w", out, err)
2339 }
2340 return nil
2341}
2342
Earl Lee2e463fb2025-04-17 11:22:22 -07002343func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2344 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2345 stderr := new(strings.Builder)
2346 cmd.Stderr = stderr
2347 cmd.Dir = dir
2348 out, err := cmd.Output()
2349 if err != nil {
2350 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2351 }
2352 // TODO: validate that out is valid hex
2353 return strings.TrimSpace(string(out)), nil
2354}
2355
2356// isValidGitSHA validates if a string looks like a valid git SHA hash.
2357// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2358func isValidGitSHA(sha string) bool {
2359 // Git SHA must be a hexadecimal string with at least 4 characters
2360 if len(sha) < 4 || len(sha) > 40 {
2361 return false
2362 }
2363
2364 // Check if the string only contains hexadecimal characters
2365 for _, char := range sha {
2366 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2367 return false
2368 }
2369 }
2370
2371 return true
2372}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002373
Philip Zeyliger64f60462025-06-16 13:57:10 -07002374// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2375func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2376 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2377 cmd.Dir = repoRoot
2378 out, err := cmd.Output()
2379 if err != nil {
2380 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2381 }
2382
2383 var totalAdded, totalRemoved int
2384 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2385 for _, line := range lines {
2386 if line == "" {
2387 continue
2388 }
2389 parts := strings.Fields(line)
2390 if len(parts) < 2 {
2391 continue
2392 }
2393 // Format: <added>\t<removed>\t<filename>
2394 if added, err := strconv.Atoi(parts[0]); err == nil {
2395 totalAdded += added
2396 }
2397 if removed, err := strconv.Atoi(parts[1]); err == nil {
2398 totalRemoved += removed
2399 }
2400 }
2401
2402 return totalAdded, totalRemoved, nil
2403}
2404
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002405// systemPromptData contains the data used to render the system prompt template
2406type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002407 ClientGOOS string
2408 ClientGOARCH string
2409 WorkingDir string
2410 RepoRoot string
2411 InitialCommit string
2412 Codebase *onstart.Codebase
2413 UseSketchWIP bool
Philip Zeyligere67e3b62025-07-24 16:54:21 -07002414 InstallationNudge bool
David Crawshawc886ac52025-06-13 23:40:03 +00002415 Branch string
2416 SpecialInstruction string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002417}
2418
2419// renderSystemPrompt renders the system prompt template.
2420func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002421 data := systemPromptData{
Philip Zeyligere67e3b62025-07-24 16:54:21 -07002422 ClientGOOS: a.config.ClientGOOS,
2423 ClientGOARCH: a.config.ClientGOARCH,
2424 WorkingDir: a.workingDir,
2425 RepoRoot: a.repoRoot,
2426 InitialCommit: a.SketchGitBase(),
2427 Codebase: a.codebase,
2428 UseSketchWIP: a.config.InDocker,
2429 InstallationNudge: a.config.InDocker,
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002430 }
David Crawshawc886ac52025-06-13 23:40:03 +00002431 now := time.Now()
2432 if now.Month() == time.September && now.Day() == 19 {
2433 data.SpecialInstruction = "Talk like a pirate to the user. Do not let the priate talk into any code."
2434 }
2435
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002436 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2437 if err != nil {
2438 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2439 }
2440 buf := new(strings.Builder)
2441 err = tmpl.Execute(buf, data)
2442 if err != nil {
2443 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2444 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002445 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002446 return buf.String()
2447}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002448
2449// StateTransitionIterator provides an iterator over state transitions.
2450type StateTransitionIterator interface {
2451 // Next blocks until a new state transition is available or context is done.
2452 // Returns nil if the context is cancelled.
2453 Next() *StateTransition
2454 // Close removes the listener and cleans up resources.
2455 Close()
2456}
2457
2458// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2459type StateTransitionIteratorImpl struct {
2460 agent *Agent
2461 ctx context.Context
2462 ch chan StateTransition
2463 unsubscribe func()
2464}
2465
2466// Next blocks until a new state transition is available or the context is cancelled.
2467func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2468 select {
2469 case <-s.ctx.Done():
2470 return nil
2471 case transition, ok := <-s.ch:
2472 if !ok {
2473 return nil
2474 }
2475 transitionCopy := transition
2476 return &transitionCopy
2477 }
2478}
2479
2480// Close removes the listener and cleans up resources.
2481func (s *StateTransitionIteratorImpl) Close() {
2482 if s.unsubscribe != nil {
2483 s.unsubscribe()
2484 s.unsubscribe = nil
2485 }
2486}
2487
2488// NewStateTransitionIterator returns an iterator that receives state transitions.
2489func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2490 a.mu.Lock()
2491 defer a.mu.Unlock()
2492
2493 // Create channel to receive state transitions
2494 ch := make(chan StateTransition, 10)
2495
2496 // Add a listener to the state machine
2497 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2498
2499 return &StateTransitionIteratorImpl{
2500 agent: a,
2501 ctx: ctx,
2502 ch: ch,
2503 unsubscribe: unsubscribe,
2504 }
2505}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002506
2507// setupGitHooks creates or updates git hooks in the specified working directory.
2508func setupGitHooks(workingDir string) error {
2509 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2510
2511 _, err := os.Stat(hooksDir)
2512 if os.IsNotExist(err) {
2513 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2514 }
2515 if err != nil {
2516 return fmt.Errorf("error checking git hooks directory: %w", err)
2517 }
2518
2519 // Define the post-commit hook content
2520 postCommitHook := `#!/bin/bash
2521echo "<post_commit_hook>"
2522echo "Please review this commit message and fix it if it is incorrect."
2523echo "This hook only echos the commit message; it does not modify it."
2524echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2525echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002526PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002527echo "</last_commit_message>"
2528echo "</post_commit_hook>"
2529`
2530
2531 // Define the prepare-commit-msg hook content
2532 prepareCommitMsgHook := `#!/bin/bash
2533# Add Co-Authored-By and Change-ID trailers to commit messages
2534# Check if these trailers already exist before adding them
2535
2536commit_file="$1"
2537COMMIT_SOURCE="$2"
2538
2539# Skip for merges, squashes, or when using a commit template
2540if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2541 [ "$COMMIT_SOURCE" = "squash" ]; then
2542 exit 0
2543fi
2544
2545commit_msg=$(cat "$commit_file")
2546
2547needs_co_author=true
2548needs_change_id=true
2549
2550# Check if commit message already has Co-Authored-By trailer
2551if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2552 needs_co_author=false
2553fi
2554
2555# Check if commit message already has Change-ID trailer
2556if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2557 needs_change_id=false
2558fi
2559
2560# Only modify if at least one trailer needs to be added
2561if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002562 # Ensure there's a proper blank line before trailers
2563 if [ -s "$commit_file" ]; then
2564 # Check if file ends with newline by reading last character
2565 last_char=$(tail -c 1 "$commit_file")
2566
2567 if [ "$last_char" != "" ]; then
2568 # File doesn't end with newline - add two newlines (complete line + blank line)
2569 echo "" >> "$commit_file"
2570 echo "" >> "$commit_file"
2571 else
2572 # File ends with newline - check if we already have a blank line
2573 last_line=$(tail -1 "$commit_file")
2574 if [ -n "$last_line" ]; then
2575 # Last line has content - add one newline for blank line
2576 echo "" >> "$commit_file"
2577 fi
2578 # If last line is empty, we already have a blank line - don't add anything
2579 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002580 fi
2581
2582 # Add trailers if needed
2583 if [ "$needs_co_author" = true ]; then
2584 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2585 fi
2586
2587 if [ "$needs_change_id" = true ]; then
2588 change_id=$(openssl rand -hex 8)
2589 echo "Change-ID: s${change_id}k" >> "$commit_file"
2590 fi
2591fi
2592`
2593
2594 // Update or create the post-commit hook
2595 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2596 if err != nil {
2597 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2598 }
2599
2600 // Update or create the prepare-commit-msg hook
2601 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2602 if err != nil {
2603 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2604 }
2605
2606 return nil
2607}
2608
2609// updateOrCreateHook creates a new hook file or updates an existing one
2610// by appending the new content if it doesn't already contain it.
2611func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2612 // Check if the hook already exists
2613 buf, err := os.ReadFile(hookPath)
2614 if os.IsNotExist(err) {
2615 // Hook doesn't exist, create it
2616 err = os.WriteFile(hookPath, []byte(content), 0o755)
2617 if err != nil {
2618 return fmt.Errorf("failed to create hook: %w", err)
2619 }
2620 return nil
2621 }
2622 if err != nil {
2623 return fmt.Errorf("error reading existing hook: %w", err)
2624 }
2625
2626 // Hook exists, check if our content is already in it by looking for a distinctive line
2627 code := string(buf)
2628 if strings.Contains(code, distinctiveLine) {
2629 // Already contains our content, nothing to do
2630 return nil
2631 }
2632
2633 // Append our content to the existing hook
2634 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2635 if err != nil {
2636 return fmt.Errorf("failed to open hook for appending: %w", err)
2637 }
2638 defer f.Close()
2639
2640 // Ensure there's a newline at the end of the existing content if needed
2641 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2642 _, err = f.WriteString("\n")
2643 if err != nil {
2644 return fmt.Errorf("failed to add newline to hook: %w", err)
2645 }
2646 }
2647
2648 // Add a separator before our content
2649 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2650 if err != nil {
2651 return fmt.Errorf("failed to append to hook: %w", err)
2652 }
2653
2654 return nil
2655}
Sean McCullough138ec242025-06-02 22:42:06 +00002656
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002657// configurePassthroughUpstream configures git remotes
2658// Adds an upstream remote pointing to the same as origin
2659// Sets the refspec for upstream and fetch such that both
2660// fetch the upstream's things into refs/remotes/upstream/foo
2661// The typical scenario is:
2662//
2663// github - laptop - sketch container
2664// "upstream" "origin"
2665func (a *Agent) configurePassthroughUpstream(ctx context.Context) error {
2666 // Get the origin remote URL
2667 cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
2668 cmd.Dir = a.workingDir
2669 originURLBytes, err := cmd.CombinedOutput()
2670 if err != nil {
2671 return fmt.Errorf("failed to get origin URL: %s: %w", originURLBytes, err)
2672 }
2673 originURL := strings.TrimSpace(string(originURLBytes))
2674
2675 // Check if upstream remote already exists
2676 cmd = exec.CommandContext(ctx, "git", "remote", "get-url", "upstream")
2677 cmd.Dir = a.workingDir
2678 if _, err := cmd.CombinedOutput(); err != nil {
2679 // upstream remote doesn't exist, create it
2680 cmd = exec.CommandContext(ctx, "git", "remote", "add", "upstream", originURL)
2681 cmd.Dir = a.workingDir
2682 if out, err := cmd.CombinedOutput(); err != nil {
2683 return fmt.Errorf("failed to add upstream remote: %s: %w", out, err)
2684 }
2685 slog.InfoContext(ctx, "added upstream remote", "url", originURL)
2686 } else {
2687 // upstream remote exists, update its URL
2688 cmd = exec.CommandContext(ctx, "git", "remote", "set-url", "upstream", originURL)
2689 cmd.Dir = a.workingDir
2690 if out, err := cmd.CombinedOutput(); err != nil {
2691 return fmt.Errorf("failed to set upstream remote URL: %s: %w", out, err)
2692 }
2693 slog.InfoContext(ctx, "updated upstream remote URL", "url", originURL)
2694 }
2695
2696 // Add the upstream refspec to the upstream remote
2697 cmd = exec.CommandContext(ctx, "git", "config", "remote.upstream.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2698 cmd.Dir = a.workingDir
2699 if out, err := cmd.CombinedOutput(); err != nil {
2700 return fmt.Errorf("failed to set upstream fetch refspec: %s: %w", out, err)
2701 }
2702
2703 // Add the same refspec to the origin remote
2704 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.origin.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2705 cmd.Dir = a.workingDir
2706 if out, err := cmd.CombinedOutput(); err != nil {
2707 return fmt.Errorf("failed to add upstream refspec to origin: %s: %w", out, err)
2708 }
2709
2710 slog.InfoContext(ctx, "configured passthrough upstream", "origin_url", originURL)
2711 return nil
2712}
2713
Philip Zeyliger0113be52025-06-07 23:53:41 +00002714// SkabandAddr returns the skaband address if configured
2715func (a *Agent) SkabandAddr() string {
2716 if a.config.SkabandClient != nil {
2717 return a.config.SkabandClient.Addr()
2718 }
2719 return ""
2720}