blob: 8339369ba3ba5d854eb17c08b2d3673ec5719cf2 [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
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +0000454 now func() time.Time // override-able, defaults to time.Now
Earl Lee2e463fb2025-04-17 11:22:22 -0700455
456 // Inbox - for messages from the user to the agent.
457 // sent on by UserMessage
458 // . e.g. when user types into the chat textarea
459 // read from by GatherMessages
460 inbox chan string
461
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000462 // protects cancelTurn
463 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700464 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000465 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700466
467 // protects following
468 mu sync.Mutex
469
470 // Stores all messages for this agent
471 history []AgentMessage
472
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700473 // Iterators add themselves here when they're ready to be notified of new messages.
474 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700475
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000476 // Track outstanding LLM call IDs
477 outstandingLLMCalls map[string]struct{}
478
479 // Track outstanding tool calls by ID with their names
480 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700481}
482
banksean5ab8fb82025-07-09 12:34:55 -0700483// TokenContextWindow implements CodingAgent.
484func (a *Agent) TokenContextWindow() int {
485 return a.config.Service.TokenContextWindow()
486}
487
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000488// ModelName returns the name of the model the agent is using.
489func (a *Agent) ModelName() string {
490 return a.config.Model
491}
492
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700493// GetConvo returns the conversation interface for debugging purposes.
494func (a *Agent) GetConvo() ConvoInterface {
495 return a.convo
496}
497
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700498// NewIterator implements CodingAgent.
499func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
500 a.mu.Lock()
501 defer a.mu.Unlock()
502
503 return &MessageIteratorImpl{
504 agent: a,
505 ctx: ctx,
506 nextMessageIdx: nextMessageIdx,
507 ch: make(chan *AgentMessage, 100),
508 }
509}
510
511type MessageIteratorImpl struct {
512 agent *Agent
513 ctx context.Context
514 nextMessageIdx int
515 ch chan *AgentMessage
516 subscribed bool
517}
518
519func (m *MessageIteratorImpl) Close() {
520 m.agent.mu.Lock()
521 defer m.agent.mu.Unlock()
522 // Delete ourselves from the subscribers list
523 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
524 return x == m.ch
525 })
526 close(m.ch)
527}
528
529func (m *MessageIteratorImpl) Next() *AgentMessage {
530 // We avoid subscription at creation to let ourselves catch up to "current state"
531 // before subscribing.
532 if !m.subscribed {
533 m.agent.mu.Lock()
534 if m.nextMessageIdx < len(m.agent.history) {
535 msg := &m.agent.history[m.nextMessageIdx]
536 m.nextMessageIdx++
537 m.agent.mu.Unlock()
538 return msg
539 }
540 // The next message doesn't exist yet, so let's subscribe
541 m.agent.subscribers = append(m.agent.subscribers, m.ch)
542 m.subscribed = true
543 m.agent.mu.Unlock()
544 }
545
546 for {
547 select {
548 case <-m.ctx.Done():
549 m.agent.mu.Lock()
550 // Delete ourselves from the subscribers list
551 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
552 return x == m.ch
553 })
554 m.subscribed = false
555 m.agent.mu.Unlock()
556 return nil
557 case msg, ok := <-m.ch:
558 if !ok {
559 // Close may have been called
560 return nil
561 }
562 if msg.Idx == m.nextMessageIdx {
563 m.nextMessageIdx++
564 return msg
565 }
566 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
567 panic("out of order message")
568 }
569 }
570}
571
Sean McCulloughd9d45812025-04-30 16:53:41 -0700572// Assert that Agent satisfies the CodingAgent interface.
573var _ CodingAgent = &Agent{}
574
575// StateName implements CodingAgent.
576func (a *Agent) CurrentStateName() string {
577 if a.stateMachine == nil {
578 return ""
579 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000580 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700581}
582
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700583// CurrentTodoContent returns the current todo list data as JSON.
584// It returns an empty string if no todos exist.
585func (a *Agent) CurrentTodoContent() string {
586 todoPath := claudetool.TodoFilePath(a.config.SessionID)
587 content, err := os.ReadFile(todoPath)
588 if err != nil {
589 return ""
590 }
591 return string(content)
592}
593
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700594// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
595func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
596 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.
597
598IMPORTANT: 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.
599
600Please create a detailed summary that includes:
601
6021. **User's Request**: What did the user originally ask me to do? What was their goal?
603
6042. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
605
6063. **Key Technical Decisions**: What important technical choices were made during our work and why?
607
6084. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
609
6105. **Next Steps**: What still needs to be done to complete the user's request?
611
6126. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
613
614Focus 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.
615
616Reply with ONLY the summary content - no meta-commentary about creating the summary.`
617
618 userMessage := llm.UserStringMessage(msg)
619 // Use a subconversation with history to get the summary
620 // TODO: We don't have any tools here, so we should have enough tokens
621 // to capture a summary, but we may need to modify the history (e.g., remove
622 // TODO data) to save on some tokens.
623 convo := a.convo.SubConvoWithHistory()
624
625 // Modify the system prompt to provide context about the original task
626 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000627 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 -0700628
629Your 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.
630
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000631Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700632
633 resp, err := convo.SendMessage(userMessage)
634 if err != nil {
635 a.pushToOutbox(ctx, errorMessage(err))
636 return "", err
637 }
638 textContent := collectTextContent(resp)
639
640 // Restore original system prompt (though this subconvo will be discarded)
641 convo.SystemPrompt = originalSystemPrompt
642
643 return textContent, nil
644}
645
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000646// dumpMessageHistoryToTmp dumps the agent's entire message history to /tmp as JSON
647// and returns the filename
648func (a *Agent) dumpMessageHistoryToTmp(ctx context.Context) (string, error) {
649 // Create a filename based on session ID and timestamp
650 timestamp := time.Now().Format("20060102-150405")
651 filename := fmt.Sprintf("/tmp/sketch-messages-%s-%s.json", a.config.SessionID, timestamp)
652
653 // Marshal the entire message history to JSON
654 jsonData, err := json.MarshalIndent(a.history, "", " ")
655 if err != nil {
656 return "", fmt.Errorf("failed to marshal message history: %w", err)
657 }
658
659 // Write to file
Autoformatter3ad8c8d2025-07-15 21:05:23 +0000660 if err := os.WriteFile(filename, jsonData, 0o644); err != nil {
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000661 return "", fmt.Errorf("failed to write message history to %s: %w", filename, err)
662 }
663
664 slog.InfoContext(ctx, "Dumped message history to file", "filename", filename, "message_count", len(a.history))
665 return filename, nil
666}
667
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700668// CompactConversation compacts the current conversation by generating a summary
669// and restarting the conversation with that summary as the initial context
670func (a *Agent) CompactConversation(ctx context.Context) error {
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000671 // Dump the entire message history to /tmp as JSON before compacting
672 dumpFile, err := a.dumpMessageHistoryToTmp(ctx)
673 if err != nil {
674 slog.WarnContext(ctx, "Failed to dump message history to /tmp", "error", err)
675 // Continue with compaction even if dump fails
676 }
677
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700678 summary, err := a.generateConversationSummary(ctx)
679 if err != nil {
680 return fmt.Errorf("failed to generate conversation summary: %w", err)
681 }
682
683 a.mu.Lock()
684
685 // Get usage information before resetting conversation
686 lastUsage := a.convo.LastUsage()
687 contextWindow := a.config.Service.TokenContextWindow()
688 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
689
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000690 // Preserve cumulative usage across compaction
691 cumulativeUsage := a.convo.CumulativeUsage()
692
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700693 // Reset conversation state but keep all other state (git, working dir, etc.)
694 a.firstMessageIndex = len(a.history)
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000695 a.convo = a.initConvoWithUsage(&cumulativeUsage)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700696
697 a.mu.Unlock()
698
699 // Create informative compaction message with token details
700 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
701 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
702 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
703
704 a.pushToOutbox(ctx, AgentMessage{
705 Type: CompactMessageType,
706 Content: compactionMsg,
707 })
708
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000709 // Create the message content with dump file information if available
710 var messageContent string
711 if dumpFile != "" {
712 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)
713 } else {
714 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)
715 }
716
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700717 a.pushToOutbox(ctx, AgentMessage{
718 Type: UserMessageType,
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000719 Content: messageContent,
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700720 })
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000721 a.inbox <- messageContent
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700722
723 return nil
724}
725
Earl Lee2e463fb2025-04-17 11:22:22 -0700726func (a *Agent) URL() string { return a.url }
727
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000728// GetPorts returns the cached list of open TCP ports.
729func (a *Agent) GetPorts() []portlist.Port {
730 if a.portMonitor == nil {
731 return nil
732 }
733 return a.portMonitor.GetPorts()
734}
735
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000736// BranchName returns the git branch name for the conversation.
737func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700738 return a.gitState.BranchName(a.config.BranchPrefix)
739}
740
741// Slug returns the slug identifier for this conversation.
742func (a *Agent) Slug() string {
743 return a.gitState.Slug()
744}
745
746// IncrementRetryNumber increments the retry number for branch naming conflicts
747func (a *Agent) IncrementRetryNumber() {
748 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000749}
750
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000751// OutstandingLLMCallCount returns the number of outstanding LLM calls.
752func (a *Agent) OutstandingLLMCallCount() int {
753 a.mu.Lock()
754 defer a.mu.Unlock()
755 return len(a.outstandingLLMCalls)
756}
757
758// OutstandingToolCalls returns the names of outstanding tool calls.
759func (a *Agent) OutstandingToolCalls() []string {
760 a.mu.Lock()
761 defer a.mu.Unlock()
762
763 tools := make([]string, 0, len(a.outstandingToolCalls))
764 for _, toolName := range a.outstandingToolCalls {
765 tools = append(tools, toolName)
766 }
767 return tools
768}
769
Earl Lee2e463fb2025-04-17 11:22:22 -0700770// OS returns the operating system of the client.
771func (a *Agent) OS() string {
772 return a.config.ClientGOOS
773}
774
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000775func (a *Agent) SessionID() string {
776 return a.config.SessionID
777}
778
philip.zeyliger8773e682025-06-11 21:36:21 -0700779// SSHConnectionString returns the SSH connection string for the container.
780func (a *Agent) SSHConnectionString() string {
781 return a.config.SSHConnectionString
782}
783
Philip Zeyliger18532b22025-04-23 21:11:46 +0000784// OutsideOS returns the operating system of the outside system.
785func (a *Agent) OutsideOS() string {
786 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000787}
788
Philip Zeyliger18532b22025-04-23 21:11:46 +0000789// OutsideHostname returns the hostname of the outside system.
790func (a *Agent) OutsideHostname() string {
791 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000792}
793
Philip Zeyliger18532b22025-04-23 21:11:46 +0000794// OutsideWorkingDir returns the working directory on the outside system.
795func (a *Agent) OutsideWorkingDir() string {
796 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000797}
798
799// GitOrigin returns the URL of the git remote 'origin' if it exists.
800func (a *Agent) GitOrigin() string {
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +0000801 return a.config.OriginalGitOrigin
Philip Zeyligerd1402952025-04-23 03:54:37 +0000802}
803
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700804// PassthroughUpstream returns whether passthrough upstream is enabled.
805func (a *Agent) PassthroughUpstream() bool {
806 return a.config.PassthroughUpstream
807}
808
bankseancad67b02025-06-27 21:57:05 +0000809// GitUsername returns the git user name from the agent config.
810func (a *Agent) GitUsername() string {
811 return a.config.GitUsername
812}
813
Philip Zeyliger64f60462025-06-16 13:57:10 -0700814// DiffStats returns the number of lines added and removed from sketch-base to HEAD
815func (a *Agent) DiffStats() (int, int) {
816 return a.gitState.DiffStats()
817}
818
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000819func (a *Agent) OpenBrowser(url string) {
820 if !a.IsInContainer() {
821 browser.Open(url)
822 return
823 }
824 // We're in Docker, need to send a request to the Git server
825 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700826 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000827 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700828 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000829 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700830 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000831 return
832 }
833 defer resp.Body.Close()
834 if resp.StatusCode == http.StatusOK {
835 return
836 }
837 body, _ := io.ReadAll(resp.Body)
838 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
839}
840
Sean McCullough96b60dd2025-04-30 09:49:10 -0700841// CurrentState returns the current state of the agent's state machine.
842func (a *Agent) CurrentState() State {
843 return a.stateMachine.CurrentState()
844}
845
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700846func (a *Agent) IsInContainer() bool {
847 return a.config.InDocker
848}
849
850func (a *Agent) FirstMessageIndex() int {
851 a.mu.Lock()
852 defer a.mu.Unlock()
853 return a.firstMessageIndex
854}
855
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700856// SetSlug sets a human-readable identifier for the conversation.
857func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700858 a.mu.Lock()
859 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700860
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700861 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000862 convo, ok := a.convo.(*conversation.Convo)
863 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700864 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000865 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700866}
867
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000868// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700869func (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 +0000870 // Track the tool call
871 a.mu.Lock()
872 a.outstandingToolCalls[id] = toolName
873 a.mu.Unlock()
874}
875
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700876// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
877// If there's only one element in the array and it's a text type, it returns that text directly.
878// It also processes nested ToolResult arrays recursively.
879func contentToString(contents []llm.Content) string {
880 if len(contents) == 0 {
881 return ""
882 }
883
884 // If there's only one element and it's a text type, return it directly
885 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
886 return contents[0].Text
887 }
888
889 // Otherwise, concatenate all text content
890 var result strings.Builder
891 for _, content := range contents {
892 if content.Type == llm.ContentTypeText {
893 result.WriteString(content.Text)
894 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
895 // Recursively process nested tool results
896 result.WriteString(contentToString(content.ToolResult))
897 }
898 }
899
900 return result.String()
901}
902
Earl Lee2e463fb2025-04-17 11:22:22 -0700903// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700904func (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 +0000905 // Remove the tool call from outstanding calls
906 a.mu.Lock()
907 delete(a.outstandingToolCalls, toolID)
908 a.mu.Unlock()
909
Earl Lee2e463fb2025-04-17 11:22:22 -0700910 m := AgentMessage{
911 Type: ToolUseMessageType,
912 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700913 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700914 ToolError: content.ToolError,
915 ToolName: toolName,
916 ToolInput: string(toolInput),
917 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700918 StartTime: content.ToolUseStartTime,
919 EndTime: content.ToolUseEndTime,
Josh Bleecher Snyder3dd3e412025-07-22 20:32:03 -0700920 Display: content.Display,
Earl Lee2e463fb2025-04-17 11:22:22 -0700921 }
922
923 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700924 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
925 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700926 m.Elapsed = &elapsed
927 }
928
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700929 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700930 a.pushToOutbox(ctx, m)
931}
932
933// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700934func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000935 a.mu.Lock()
936 defer a.mu.Unlock()
937 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700938 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
939}
940
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700941// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700942// that need to be displayed (as well as tool calls that we send along when
943// they're done). (It would be reasonable to also mention tool calls when they're
944// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700945func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000946 // Remove the LLM call from outstanding calls
947 a.mu.Lock()
948 delete(a.outstandingLLMCalls, id)
949 a.mu.Unlock()
950
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700951 if resp == nil {
952 // LLM API call failed
953 m := AgentMessage{
954 Type: ErrorMessageType,
955 Content: "API call failed, type 'continue' to try again",
956 }
957 m.SetConvo(convo)
958 a.pushToOutbox(ctx, m)
959 return
960 }
961
Earl Lee2e463fb2025-04-17 11:22:22 -0700962 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700963 if convo.Parent == nil { // subconvos never end the turn
964 switch resp.StopReason {
965 case llm.StopReasonToolUse:
966 // Check whether any of the tool calls are for tools that should end the turn
967 ToolSearch:
968 for _, part := range resp.Content {
969 if part.Type != llm.ContentTypeToolUse {
970 continue
971 }
Sean McCullough021557a2025-05-05 23:20:53 +0000972 // Find the tool by name
973 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700974 if tool.Name == part.ToolName {
975 endOfTurn = tool.EndsTurn
976 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000977 }
978 }
Sean McCullough021557a2025-05-05 23:20:53 +0000979 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700980 default:
981 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000982 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700983 }
984 m := AgentMessage{
985 Type: AgentMessageType,
986 Content: collectTextContent(resp),
987 EndOfTurn: endOfTurn,
988 Usage: &resp.Usage,
989 StartTime: resp.StartTime,
990 EndTime: resp.EndTime,
991 }
992
993 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700994 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700995 var toolCalls []ToolCall
996 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700997 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -0700998 toolCalls = append(toolCalls, ToolCall{
999 Name: part.ToolName,
1000 Input: string(part.ToolInput),
1001 ToolCallId: part.ID,
1002 })
1003 }
1004 }
1005 m.ToolCalls = toolCalls
1006 }
1007
1008 // Calculate the elapsed time if both start and end times are set
1009 if resp.StartTime != nil && resp.EndTime != nil {
1010 elapsed := resp.EndTime.Sub(*resp.StartTime)
1011 m.Elapsed = &elapsed
1012 }
1013
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -07001014 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -07001015 a.pushToOutbox(ctx, m)
1016}
1017
1018// WorkingDir implements CodingAgent.
1019func (a *Agent) WorkingDir() string {
1020 return a.workingDir
1021}
1022
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001023// RepoRoot returns the git repository root directory.
1024func (a *Agent) RepoRoot() string {
1025 return a.repoRoot
1026}
1027
Earl Lee2e463fb2025-04-17 11:22:22 -07001028// MessageCount implements CodingAgent.
1029func (a *Agent) MessageCount() int {
1030 a.mu.Lock()
1031 defer a.mu.Unlock()
1032 return len(a.history)
1033}
1034
1035// Messages implements CodingAgent.
1036func (a *Agent) Messages(start int, end int) []AgentMessage {
1037 a.mu.Lock()
1038 defer a.mu.Unlock()
1039 return slices.Clone(a.history[start:end])
1040}
1041
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001042// ShouldCompact checks if the conversation should be compacted based on token usage
1043func (a *Agent) ShouldCompact() bool {
1044 // Get the threshold from environment variable, default to 0.94 (94%)
1045 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
1046 // and a little bit of buffer.)
1047 thresholdRatio := 0.94
1048 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
1049 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
1050 thresholdRatio = parsed
1051 }
1052 }
1053
1054 // Get the most recent usage to check current context size
1055 lastUsage := a.convo.LastUsage()
1056
1057 if lastUsage.InputTokens == 0 {
1058 // No API calls made yet
1059 return false
1060 }
1061
1062 // Calculate the current context size from the last API call
1063 // This includes all tokens that were part of the input context:
1064 // - Input tokens (user messages, system prompt, conversation history)
1065 // - Cache read tokens (cached parts of the context)
1066 // - Cache creation tokens (new parts being cached)
1067 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
1068
1069 // Get the service's token context window
1070 service := a.config.Service
1071 contextWindow := service.TokenContextWindow()
1072
1073 // Calculate threshold
1074 threshold := uint64(float64(contextWindow) * thresholdRatio)
1075
1076 // Check if we've exceeded the threshold
1077 return currentContextSize >= threshold
1078}
1079
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001080func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -07001081 return a.originalBudget
1082}
1083
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001084// Upstream returns the upstream branch for git work
1085func (a *Agent) Upstream() string {
1086 return a.gitState.Upstream()
1087}
1088
Earl Lee2e463fb2025-04-17 11:22:22 -07001089// AgentConfig contains configuration for creating a new Agent.
1090type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001091 Context context.Context
1092 Service llm.Service
1093 Budget conversation.Budget
1094 GitUsername string
1095 GitEmail string
1096 SessionID string
1097 ClientGOOS string
1098 ClientGOARCH string
1099 InDocker bool
1100 OneShot bool
1101 WorkingDir string
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +00001102 // Model is the name of the LLM model being used
1103 Model string
Philip Zeyliger18532b22025-04-23 21:11:46 +00001104 // Outside information
1105 OutsideHostname string
1106 OutsideOS string
1107 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001108
1109 // Outtie's HTTP to, e.g., open a browser
1110 OutsideHTTP string
1111 // Outtie's Git server
1112 GitRemoteAddr string
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001113 // Original git origin URL from host repository, if any
1114 OriginalGitOrigin string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001115 // Upstream branch for git work
1116 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001117 // Commit to checkout from Outtie
1118 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001119 // Prefix for git branches created by sketch
1120 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001121 // LinkToGitHub enables GitHub branch linking in UI
1122 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001123 // SSH connection string for connecting to the container
1124 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001125 // Skaband client for session history (optional)
1126 SkabandClient *skabandclient.SkabandClient
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001127 // MCP server configurations
1128 MCPServers []string
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001129 // Timeout configuration for bash tool
1130 BashTimeouts *claudetool.Timeouts
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001131 // PassthroughUpstream configures upstream remote for passthrough to innie
1132 PassthroughUpstream bool
Josh Bleecher Snyder1e551672025-07-30 03:16:54 +00001133 // FetchOnLaunch enables git fetch during initialization
1134 FetchOnLaunch bool
Earl Lee2e463fb2025-04-17 11:22:22 -07001135}
1136
1137// NewAgent creates a new Agent.
1138// It is not usable until Init() is called.
1139func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001140 // Set default branch prefix if not specified
1141 if config.BranchPrefix == "" {
1142 config.BranchPrefix = "sketch/"
1143 }
1144
Earl Lee2e463fb2025-04-17 11:22:22 -07001145 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001146 config: config,
1147 ready: make(chan struct{}),
1148 inbox: make(chan string, 100),
1149 subscribers: make([]chan *AgentMessage, 0),
1150 startedAt: time.Now(),
1151 originalBudget: config.Budget,
1152 gitState: AgentGitState{
1153 seenCommits: make(map[string]bool),
1154 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001155 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001156 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001157 outsideHostname: config.OutsideHostname,
1158 outsideOS: config.OutsideOS,
1159 outsideWorkingDir: config.OutsideWorkingDir,
1160 outstandingLLMCalls: make(map[string]struct{}),
1161 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001162 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001163 workingDir: config.WorkingDir,
1164 outsideHTTP: config.OutsideHTTP,
Philip Zeyligerda623b52025-07-04 01:12:38 +00001165
1166 mcpManager: mcp.NewMCPManager(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001167 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001168
1169 // Initialize port monitor with 5-second interval
1170 agent.portMonitor = NewPortMonitor(agent, 5*time.Second)
1171
Earl Lee2e463fb2025-04-17 11:22:22 -07001172 return agent
1173}
1174
1175type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001176 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001177
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001178 InDocker bool
1179 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001180}
1181
1182func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001183 if a.convo != nil {
1184 return fmt.Errorf("Agent.Init: already initialized")
1185 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001186 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001187 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001188
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001189 // If a remote + commit was specified, clone it.
1190 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001191 if _, err := os.Stat("/app/.git"); err != nil {
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00001192 slog.InfoContext(ctx, "cloning git repo", "commit", a.config.Commit)
1193 // TODO: --reference-if-able instead?
1194 cmd := exec.CommandContext(ctx, "git", "clone", "--reference", "/git-ref", a.gitState.gitRemoteAddr, "/app")
1195 if out, err := cmd.CombinedOutput(); err != nil {
1196 return fmt.Errorf("failed to clone repository from %s: %s: %w", a.gitState.gitRemoteAddr, out, err)
1197 }
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001198 }
1199 }
1200
1201 if a.workingDir != "" {
1202 err := os.Chdir(a.workingDir)
1203 if err != nil {
1204 return fmt.Errorf("failed to change working directory to %s: %w", a.workingDir, err)
1205 }
1206 }
1207
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001208 if !ini.NoGit {
Philip Zeyligeraccf37c2025-07-18 07:29:19 -07001209 if a.gitState.gitRemoteAddr != "" {
1210 if err := upsertRemoteOrigin(ctx, "/app", a.gitState.gitRemoteAddr); err != nil {
1211 return err
1212 }
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001213 }
Philip Zeyligere1c8b7b2025-07-03 14:50:26 -07001214
1215 // Configure git user settings
1216 if a.config.GitEmail != "" {
1217 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.email", a.config.GitEmail)
1218 cmd.Dir = a.workingDir
1219 if out, err := cmd.CombinedOutput(); err != nil {
1220 return fmt.Errorf("git config --global user.email: %s: %v", out, err)
1221 }
1222 }
1223 if a.config.GitUsername != "" {
1224 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.name", a.config.GitUsername)
1225 cmd.Dir = a.workingDir
1226 if out, err := cmd.CombinedOutput(); err != nil {
1227 return fmt.Errorf("git config --global user.name: %s: %v", out, err)
1228 }
1229 }
1230 // Configure git http.postBuffer
1231 cmd := exec.CommandContext(ctx, "git", "config", "--global", "http.postBuffer", "524288000")
1232 cmd.Dir = a.workingDir
1233 if out, err := cmd.CombinedOutput(); err != nil {
1234 return fmt.Errorf("git config --global http.postBuffer: %s: %v", out, err)
1235 }
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001236
1237 // Configure passthrough upstream if enabled
1238 if a.config.PassthroughUpstream {
1239 if err := a.configurePassthroughUpstream(ctx); err != nil {
1240 return fmt.Errorf("failed to configure passthrough upstream: %w", err)
1241 }
1242 }
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001243 }
1244
Philip Zeyligerf2872992025-05-22 10:35:28 -07001245 // If a commit was specified, we fetch and reset to it.
1246 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Josh Bleecher Snyder1e551672025-07-30 03:16:54 +00001247 if a.config.FetchOnLaunch {
1248 slog.InfoContext(ctx, "updating git repo", "commit", a.config.Commit)
1249 cmd := exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
1250 cmd.Dir = a.workingDir
1251 if out, err := cmd.CombinedOutput(); err != nil {
1252 return fmt.Errorf("git fetch: %s: %w", out, err)
1253 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001254 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001255 // The -B resets the branch if it already exists (or creates it if it doesn't)
Josh Bleecher Snyder1e551672025-07-30 03:16:54 +00001256 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001257 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001258 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1259 // Remove git hooks if they exist and retry
1260 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001261 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001262 if _, statErr := os.Stat(hookPath); statErr == nil {
1263 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1264 slog.String("error", err.Error()),
1265 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001266 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001267 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1268 }
1269
1270 // Retry the checkout operation
Philip Zeyliger1417b692025-06-12 11:07:04 -07001271 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001272 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001273 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001274 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 +01001275 }
1276 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001277 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001278 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001279 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001280 } else if a.IsInContainer() {
1281 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1282 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1283 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1284 cmd.Dir = a.workingDir
1285 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1286 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1287 }
1288 } else {
1289 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001290 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001291
1292 if ini.HostAddr != "" {
1293 a.url = "http://" + ini.HostAddr
1294 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001295
1296 if !ini.NoGit {
1297 repoRoot, err := repoRoot(ctx, a.workingDir)
1298 if err != nil {
1299 return fmt.Errorf("repoRoot: %w", err)
1300 }
1301 a.repoRoot = repoRoot
1302
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001303 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001304 if err := setupGitHooks(a.repoRoot); err != nil {
1305 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1306 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001307 }
1308
philz24613202025-07-15 20:56:21 -07001309 // Check if we have any commits, and if not, create an empty initial commit
1310 cmd := exec.CommandContext(ctx, "git", "rev-list", "--all", "--count")
1311 cmd.Dir = repoRoot
1312 countOut, err := cmd.CombinedOutput()
1313 if err != nil {
1314 return fmt.Errorf("git rev-list --all --count: %s: %w", countOut, err)
1315 }
1316 commitCount := strings.TrimSpace(string(countOut))
1317 if commitCount == "0" {
1318 slog.Info("No commits found, creating empty initial commit")
1319 cmd = exec.CommandContext(ctx, "git", "commit", "--allow-empty", "-m", "Initial empty commit")
1320 cmd.Dir = repoRoot
1321 if commitOut, err := cmd.CombinedOutput(); err != nil {
1322 return fmt.Errorf("git commit --allow-empty: %s: %w", commitOut, err)
1323 }
1324 }
1325
1326 cmd = exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
Philip Zeyliger49edc922025-05-14 09:45:45 -07001327 cmd.Dir = repoRoot
1328 if out, err := cmd.CombinedOutput(); err != nil {
1329 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1330 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001331
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001332 slog.Info("running codebase analysis")
1333 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1334 if err != nil {
1335 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001336 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001337 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001338
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001339 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001340 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001341 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001342 }
1343 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001344
Earl Lee2e463fb2025-04-17 11:22:22 -07001345 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001346 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001347 a.convo = a.initConvo()
1348 close(a.ready)
1349 return nil
1350}
1351
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001352//go:embed agent_system_prompt.txt
1353var agentSystemPrompt string
1354
Earl Lee2e463fb2025-04-17 11:22:22 -07001355// initConvo initializes the conversation.
1356// It must not be called until all agent fields are initialized,
1357// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001358func (a *Agent) initConvo() *conversation.Convo {
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001359 return a.initConvoWithUsage(nil)
1360}
1361
1362// initConvoWithUsage initializes the conversation with optional preserved usage.
1363func (a *Agent) initConvoWithUsage(usage *conversation.CumulativeUsage) *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001364 ctx := a.config.Context
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001365 convo := conversation.New(ctx, a.config.Service, usage)
Earl Lee2e463fb2025-04-17 11:22:22 -07001366 convo.PromptCaching = true
1367 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001368 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001369 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001370
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001371 bashTool := &claudetool.BashTool{
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001372 EnableJITInstall: claudetool.EnableBashToolJITInstall,
1373 Timeouts: a.config.BashTimeouts,
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -07001374 Pwd: a.workingDir,
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001375 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001376
Earl Lee2e463fb2025-04-17 11:22:22 -07001377 // Register all tools with the conversation
1378 // When adding, removing, or modifying tools here, double-check that the termui tool display
1379 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001380
1381 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001382 _, supportsScreenshots := a.config.Service.(*ant.Service)
1383 var bTools []*llm.Tool
1384 var browserCleanup func()
1385
1386 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1387 // Add cleanup function to context cancel
1388 go func() {
1389 <-a.config.Context.Done()
1390 browserCleanup()
1391 }()
1392 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001393
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001394 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd64bc912025-07-24 11:42:33 -07001395 bashTool.Tool(),
1396 claudetool.Keyword,
1397 claudetool.Patch(a.patchCallback),
1398 claudetool.Think,
1399 claudetool.TodoRead,
1400 claudetool.TodoWrite,
1401 makeDoneTool(a.codereview),
1402 a.codereview.Tool(),
1403 claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001404 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001405 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001406
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001407 // Add MCP tools if configured
1408 if len(a.config.MCPServers) > 0 {
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001409
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001410 slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers))
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001411 serverConfigs, parseErrors := mcp.ParseServerConfigs(ctx, a.config.MCPServers)
1412
1413 // Replace any headers with value _sketch_public_key_ and _sketch_session_id_ with those values.
1414 for i := range serverConfigs {
1415 if serverConfigs[i].Headers != nil {
1416 for key, value := range serverConfigs[i].Headers {
Philip Zeyligerf2814ea2025-06-30 10:16:50 -07001417 // Replace env placeholders. E.g., "env:FOO" becomes os.Getenv("FOO")
1418 if strings.HasPrefix(value, "env:") {
1419 serverConfigs[i].Headers[key] = os.Getenv(value[4:])
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001420 }
1421 }
1422 }
1423 }
Philip Zeyligerc540df72025-07-25 09:21:56 -07001424 mcpConnections, mcpErrors := a.mcpManager.ConnectToServerConfigs(ctx, serverConfigs, mcp.DefaultMCPConnectionTimeout, parseErrors)
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001425
1426 if len(mcpErrors) > 0 {
1427 for _, err := range mcpErrors {
1428 slog.ErrorContext(ctx, "MCP connection error", "error", err)
1429 // Send agent message about MCP connection failures
1430 a.pushToOutbox(ctx, AgentMessage{
1431 Type: ErrorMessageType,
1432 Content: fmt.Sprintf("MCP server connection failed: %v", err),
1433 })
1434 }
1435 }
1436
1437 if len(mcpConnections) > 0 {
1438 // Add tools from all successful connections
1439 totalTools := 0
1440 for _, connection := range mcpConnections {
1441 convo.Tools = append(convo.Tools, connection.Tools...)
1442 totalTools += len(connection.Tools)
1443 // Log tools per server using structured data
1444 slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames)
1445 }
1446 slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools)
1447 } else {
1448 slog.InfoContext(ctx, "No MCP tools available after connection attempts")
1449 }
1450 }
1451
Earl Lee2e463fb2025-04-17 11:22:22 -07001452 convo.Listener = a
1453 return convo
1454}
1455
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001456// branchExists reports whether branchName exists, either locally or in well-known remotes.
1457func branchExists(dir, branchName string) bool {
1458 refs := []string{
1459 "refs/heads/",
1460 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001461 }
1462 for _, ref := range refs {
1463 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1464 cmd.Dir = dir
1465 if cmd.Run() == nil { // exit code 0 means branch exists
1466 return true
1467 }
1468 }
1469 return false
1470}
1471
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001472func soleText(contents []llm.Content) (string, error) {
1473 if len(contents) != 1 {
1474 return "", fmt.Errorf("multiple contents %v", contents)
1475 }
1476 content := contents[0]
1477 if content.Type != llm.ContentTypeText || content.Text == "" {
1478 return "", fmt.Errorf("bad content %v", content)
1479 }
1480 return strings.TrimSpace(content.Text), nil
1481}
1482
1483// autoGenerateSlug automatically generates a slug based on the first user input
1484func (a *Agent) autoGenerateSlug(ctx context.Context, userContents []llm.Content) error {
1485 userText, err := soleText(userContents)
1486 if err != nil {
1487 return err
1488 }
1489 if userText == "" {
1490 return fmt.Errorf("set-slug: empty text content")
1491 }
1492
1493 // Create a subconversation without history for slug generation
1494 convo, ok := a.convo.(*conversation.Convo)
1495 if !ok {
1496 // In test environments, the conversation might be a mock interface
1497 // Skip slug generation in this case
1498 return fmt.Errorf("set-slug: can't make a subconvo (mock convo?)")
1499 }
1500
1501 // Loop until we find an acceptable slug
1502 var unavailableSlugs []string
1503 for {
1504 if len(unavailableSlugs) > 10 {
1505 // sanity check to prevent infinite loops
1506 return fmt.Errorf("set-slug: failed to construct a new slug after %d attempts", len(unavailableSlugs))
Earl Lee2e463fb2025-04-17 11:22:22 -07001507 }
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001508 subConvo := convo.SubConvo()
1509 subConvo.Hidden = true
1510
1511 // Prompt for slug generation
1512 prompt := `You are a slug generator for Sketch, an agentic coding environment.
1513The user's prompt will be in <user-prompt> tags. Any unavailable slugs will be listed in <unavailable-slug> tags.
1514Generate a 2-3 word alphanumeric hyphenated slug in imperative tense that captures the essence of their coding task.
1515Respond with only the slug.`
1516
1517 buf := new(strings.Builder)
1518 buf.WriteString("<slug-request>")
1519 if len(unavailableSlugs) > 0 {
1520 buf.WriteString("<unavailable-slugs>")
1521 }
1522 for _, x := range unavailableSlugs {
1523 buf.WriteString("<unavailable-slug>")
1524 buf.WriteString(x)
1525 buf.WriteString("</unavailable-slug>")
1526 }
1527 if len(unavailableSlugs) > 0 {
1528 buf.WriteString("</unavailable-slugs>")
1529 }
1530 buf.WriteString("<user-prompt>")
1531 buf.WriteString(userText)
1532 buf.WriteString("</user-prompt>")
1533 buf.WriteString("</slug-request>")
1534
1535 fullPrompt := prompt + "\n" + buf.String()
1536 userMessage := llm.UserStringMessage(fullPrompt)
1537
1538 resp, err := subConvo.SendMessage(userMessage)
1539 if err != nil {
1540 return fmt.Errorf("failed to generate slug: %w", err)
1541 }
1542
1543 // Extract the slug from the response
1544 slugText, err := soleText(resp.Content)
1545 if err != nil {
1546 return err
1547 }
1548 if slugText == "" {
1549 return fmt.Errorf("empty slug generated")
1550 }
1551
1552 // Clean and validate the slug
1553 slug := cleanSlugName(slugText)
1554 if slug == "" {
1555 return fmt.Errorf("slug could not be cleaned: %q", slugText)
1556 }
1557
1558 // Check if branch already exists using the same logic as the original set-slug tool
1559 a.SetSlug(slug) // Set slug first so BranchName() works correctly
1560 if branchExists(a.workingDir, a.BranchName()) {
1561 // try again
1562 unavailableSlugs = append(unavailableSlugs, slug)
1563 continue
1564 }
1565
1566 // Success! Slug is available and already set
1567 return nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001568 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001569}
1570
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001571// patchCallback is the agent's patch tool callback.
1572// It warms the codereview cache in the background.
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001573func (a *Agent) patchCallback(input claudetool.PatchInput, output llm.ToolOut) llm.ToolOut {
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001574 if a.codereview != nil {
1575 a.codereview.WarmTestCache(input.Path)
1576 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001577 return output
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001578}
1579
Earl Lee2e463fb2025-04-17 11:22:22 -07001580func (a *Agent) Ready() <-chan struct{} {
1581 return a.ready
1582}
1583
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001584// BranchPrefix returns the configured branch prefix
1585func (a *Agent) BranchPrefix() string {
1586 return a.config.BranchPrefix
1587}
1588
philip.zeyliger6d3de482025-06-10 19:38:14 -07001589// LinkToGitHub returns whether GitHub branch linking is enabled
1590func (a *Agent) LinkToGitHub() bool {
1591 return a.config.LinkToGitHub
1592}
1593
Earl Lee2e463fb2025-04-17 11:22:22 -07001594func (a *Agent) UserMessage(ctx context.Context, msg string) {
1595 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1596 a.inbox <- msg
1597}
1598
Earl Lee2e463fb2025-04-17 11:22:22 -07001599func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1600 return a.convo.CancelToolUse(toolUseID, cause)
1601}
1602
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001603func (a *Agent) CancelTurn(cause error) {
1604 a.cancelTurnMu.Lock()
1605 defer a.cancelTurnMu.Unlock()
1606 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001607 // Force state transition to cancelled state
1608 ctx := a.config.Context
1609 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001610 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001611 }
1612}
1613
1614func (a *Agent) Loop(ctxOuter context.Context) {
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001615 // Start port monitoring
1616 if a.portMonitor != nil && a.IsInContainer() {
1617 if err := a.portMonitor.Start(ctxOuter); err != nil {
1618 slog.WarnContext(ctxOuter, "Failed to start port monitor", "error", err)
1619 } else {
1620 slog.InfoContext(ctxOuter, "Port monitor started")
1621 }
1622 }
1623
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001624 // Set up cleanup when context is done
1625 defer func() {
1626 if a.mcpManager != nil {
1627 a.mcpManager.Close()
1628 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001629 if a.portMonitor != nil && a.IsInContainer() {
1630 a.portMonitor.Stop()
1631 }
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001632 }()
1633
Earl Lee2e463fb2025-04-17 11:22:22 -07001634 for {
1635 select {
1636 case <-ctxOuter.Done():
1637 return
1638 default:
1639 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001640 a.cancelTurnMu.Lock()
1641 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001642 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001643 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001644 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001645 a.cancelTurn = cancel
1646 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001647 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1648 if err != nil {
1649 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1650 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001651 cancel(nil)
1652 }
1653 }
1654}
1655
1656func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1657 if m.Timestamp.IsZero() {
1658 m.Timestamp = time.Now()
1659 }
1660
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001661 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1662 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1663 m.Content = m.ToolResult
1664 }
1665
Earl Lee2e463fb2025-04-17 11:22:22 -07001666 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1667 if m.EndOfTurn && m.Type == AgentMessageType {
1668 turnDuration := time.Since(a.startOfTurn)
1669 m.TurnDuration = &turnDuration
1670 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1671 }
1672
Earl Lee2e463fb2025-04-17 11:22:22 -07001673 a.mu.Lock()
1674 defer a.mu.Unlock()
1675 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001676 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001677 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001678
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001679 // Notify all subscribers
1680 for _, ch := range a.subscribers {
1681 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001682 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001683}
1684
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001685func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1686 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001687 if block {
1688 select {
1689 case <-ctx.Done():
1690 return m, ctx.Err()
1691 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001692 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001693 }
1694 }
1695 for {
1696 select {
1697 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001698 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001699 default:
1700 return m, nil
1701 }
1702 }
1703}
1704
Sean McCullough885a16a2025-04-30 02:49:25 +00001705// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001706func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001707 // Reset the start of turn time
1708 a.startOfTurn = time.Now()
1709
Sean McCullough96b60dd2025-04-30 09:49:10 -07001710 // Transition to waiting for user input state
1711 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1712
Sean McCullough885a16a2025-04-30 02:49:25 +00001713 // Process initial user message
1714 initialResp, err := a.processUserMessage(ctx)
1715 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001716 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001717 return err
1718 }
1719
1720 // Handle edge case where both initialResp and err are nil
1721 if initialResp == nil {
1722 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001723 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1724
Sean McCullough9f4b8082025-04-30 17:34:07 +00001725 a.pushToOutbox(ctx, errorMessage(err))
1726 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001727 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001728
Earl Lee2e463fb2025-04-17 11:22:22 -07001729 // We do this as we go, but let's also do it at the end of the turn
1730 defer func() {
1731 if _, err := a.handleGitCommits(ctx); err != nil {
1732 // Just log the error, don't stop execution
1733 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1734 }
1735 }()
1736
Sean McCullougha1e0e492025-05-01 10:51:08 -07001737 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001738 resp := initialResp
1739 for {
1740 // Check if we are over budget
1741 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001742 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001743 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001744 }
1745
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001746 // Check if we should compact the conversation
1747 if a.ShouldCompact() {
1748 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1749 if err := a.CompactConversation(ctx); err != nil {
1750 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1751 return err
1752 }
1753 // After compaction, end this turn and start fresh
1754 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1755 return nil
1756 }
1757
Sean McCullough885a16a2025-04-30 02:49:25 +00001758 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001759 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001760 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001761 break
1762 }
1763
Sean McCullough96b60dd2025-04-30 09:49:10 -07001764 // Transition to tool use requested state
1765 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1766
Sean McCullough885a16a2025-04-30 02:49:25 +00001767 // Handle tool execution
1768 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1769 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001770 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001771 }
1772
Sean McCullougha1e0e492025-05-01 10:51:08 -07001773 if toolResp == nil {
1774 return fmt.Errorf("cannot continue conversation with a nil tool response")
1775 }
1776
Sean McCullough885a16a2025-04-30 02:49:25 +00001777 // Set the response for the next iteration
1778 resp = toolResp
1779 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001780
1781 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001782}
1783
1784// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001785func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001786 // Wait for at least one message from the user
1787 msgs, err := a.GatherMessages(ctx, true)
1788 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001789 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001790 return nil, err
1791 }
1792
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001793 // Auto-generate slug if this is the first user input and no slug is set
1794 if a.Slug() == "" {
1795 if err := a.autoGenerateSlug(ctx, msgs); err != nil {
1796 // NB: it is possible that autoGenerateSlug set the slug during the process
1797 // of trying to generate a slug.
1798 // The fact that it returned an error means that we cannot use that slug.
1799 slog.WarnContext(ctx, "Failed to auto-generate slug", "error", err)
1800 // use the session id instead. ugly, but we need a slug, and this will be unique.
1801 a.SetSlug(a.SessionID())
1802 }
1803 // Notify termui of the final slug (only emitted once, after slug is determined)
1804 a.pushToOutbox(ctx, AgentMessage{
1805 Type: SlugMessageType,
1806 Content: a.Slug(),
1807 })
1808 }
1809
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001810 userMessage := llm.Message{
1811 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001812 Content: msgs,
1813 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001814
Sean McCullough96b60dd2025-04-30 09:49:10 -07001815 // Transition to sending to LLM state
1816 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1817
Sean McCullough885a16a2025-04-30 02:49:25 +00001818 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001819 resp, err := a.convo.SendMessage(userMessage)
1820 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001821 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001822 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001823 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001824 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001825
Sean McCullough96b60dd2025-04-30 09:49:10 -07001826 // Transition to processing LLM response state
1827 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1828
Sean McCullough885a16a2025-04-30 02:49:25 +00001829 return resp, nil
1830}
1831
1832// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001833func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1834 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001835 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001836 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001837
Sean McCullough96b60dd2025-04-30 09:49:10 -07001838 // Transition to checking for cancellation state
1839 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1840
Sean McCullough885a16a2025-04-30 02:49:25 +00001841 // Check if the operation was cancelled by the user
1842 select {
1843 case <-ctx.Done():
1844 // Don't actually run any of the tools, but rather build a response
1845 // for each tool_use message letting the LLM know that user canceled it.
1846 var err error
1847 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001848 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001849 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001850 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001851 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001852 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001853 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001854 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001855 // Transition to running tool state
1856 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1857
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001858 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001859 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001860 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001861
1862 // Execute the tools
1863 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001864 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001865 if ctx.Err() != nil { // e.g. the user canceled the operation
1866 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001867 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001868 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001869 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001870 a.pushToOutbox(ctx, errorMessage(err))
1871 }
1872 }
1873
1874 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001875 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001876 autoqualityMessages := a.processGitChanges(ctx)
1877
1878 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001879 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001880 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001881 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001882 return false, nil
1883 }
1884
1885 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001886 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1887 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001888}
1889
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001890// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001891func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001892 // Check for git commits
1893 _, err := a.handleGitCommits(ctx)
1894 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001895 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001896 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001897 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001898 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001899}
1900
1901// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1902// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001903func (a *Agent) processGitChanges(ctx context.Context) []string {
1904 // Check for git commits after tool execution
1905 newCommits, err := a.handleGitCommits(ctx)
1906 if err != nil {
1907 // Just log the error, don't stop execution
1908 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1909 return nil
1910 }
1911
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001912 // Run mechanical checks if there was exactly one new commit.
1913 if len(newCommits) != 1 {
1914 return nil
1915 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001916 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001917 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1918 msg := a.codereview.RunMechanicalChecks(ctx)
1919 if msg != "" {
1920 a.pushToOutbox(ctx, AgentMessage{
1921 Type: AutoMessageType,
1922 Content: msg,
1923 Timestamp: time.Now(),
1924 })
1925 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001926 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001927
1928 return autoqualityMessages
1929}
1930
1931// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001932func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001933 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001934 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001935 msgs, err := a.GatherMessages(ctx, false)
1936 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001937 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001938 return false, nil
1939 }
1940
1941 // Inject any auto-generated messages from quality checks
1942 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001943 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001944 }
1945
1946 // Handle cancellation by appending a message about it
1947 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001948 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001949 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001950 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001951 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1952 } else if err := a.convo.OverBudget(); err != nil {
1953 // Handle budget issues by appending a message about it
1954 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 -07001955 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001956 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1957 }
1958
1959 // Combine tool results with user messages
1960 results = append(results, msgs...)
1961
1962 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001963 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001964 resp, err := a.convo.SendMessage(llm.Message{
1965 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001966 Content: results,
1967 })
1968 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001969 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001970 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1971 return true, nil // Return true to continue the conversation, but with no response
1972 }
1973
Sean McCullough96b60dd2025-04-30 09:49:10 -07001974 // Transition back to processing LLM response
1975 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1976
Sean McCullough885a16a2025-04-30 02:49:25 +00001977 if cancelled {
1978 return false, nil
1979 }
1980
1981 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07001982}
1983
1984func (a *Agent) overBudget(ctx context.Context) error {
1985 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001986 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001987 m := budgetMessage(err)
1988 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07001989 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001990 a.convo.ResetBudget(a.originalBudget)
1991 return err
1992 }
1993 return nil
1994}
1995
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001996func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07001997 // Collect all text content
1998 var allText strings.Builder
1999 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002000 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07002001 if allText.Len() > 0 {
2002 allText.WriteString("\n\n")
2003 }
2004 allText.WriteString(content.Text)
2005 }
2006 }
2007 return allText.String()
2008}
2009
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002010func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07002011 a.mu.Lock()
2012 defer a.mu.Unlock()
2013 return a.convo.CumulativeUsage()
2014}
2015
Earl Lee2e463fb2025-04-17 11:22:22 -07002016// Diff returns a unified diff of changes made since the agent was instantiated.
2017func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07002018 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07002019 return "", fmt.Errorf("no initial commit reference available")
2020 }
2021
2022 // Find the repository root
2023 ctx := context.Background()
2024
2025 // If a specific commit hash is provided, show just that commit's changes
2026 if commit != nil && *commit != "" {
2027 // Validate that the commit looks like a valid git SHA
2028 if !isValidGitSHA(*commit) {
2029 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
2030 }
2031
2032 // Get the diff for just this commit
2033 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
2034 cmd.Dir = a.repoRoot
2035 output, err := cmd.CombinedOutput()
2036 if err != nil {
2037 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
2038 }
2039 return string(output), nil
2040 }
2041
2042 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07002043 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07002044 cmd.Dir = a.repoRoot
2045 output, err := cmd.CombinedOutput()
2046 if err != nil {
2047 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
2048 }
2049
2050 return string(output), nil
2051}
2052
Philip Zeyliger49edc922025-05-14 09:45:45 -07002053// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
2054// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
2055func (a *Agent) SketchGitBaseRef() string {
2056 if a.IsInContainer() {
2057 return "sketch-base"
2058 } else {
2059 return "sketch-base-" + a.SessionID()
2060 }
2061}
2062
2063// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
2064func (a *Agent) SketchGitBase() string {
2065 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
2066 cmd.Dir = a.repoRoot
2067 output, err := cmd.CombinedOutput()
2068 if err != nil {
2069 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
2070 return "HEAD"
2071 }
2072 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002073}
2074
Pokey Rule7a113622025-05-12 10:58:45 +01002075// removeGitHooks removes the Git hooks directory from the repository
2076func removeGitHooks(_ context.Context, repoPath string) error {
2077 hooksDir := filepath.Join(repoPath, ".git", "hooks")
2078
2079 // Check if hooks directory exists
2080 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
2081 // Directory doesn't exist, nothing to do
2082 return nil
2083 }
2084
2085 // Remove the hooks directory
2086 err := os.RemoveAll(hooksDir)
2087 if err != nil {
2088 return fmt.Errorf("failed to remove git hooks directory: %w", err)
2089 }
2090
2091 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00002092 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01002093 if err != nil {
2094 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
2095 }
2096
2097 return nil
2098}
2099
Philip Zeyligerf2872992025-05-22 10:35:28 -07002100func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002101 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002102 for _, msg := range msgs {
2103 a.pushToOutbox(ctx, msg)
2104 }
2105 return commits, error
2106}
2107
Earl Lee2e463fb2025-04-17 11:22:22 -07002108// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002109// under docker, new HEADs are pushed to a branch according to the slug.
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002110func (ags *AgentGitState) handleGitCommits(ctx context.Context, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002111 ags.mu.Lock()
2112 defer ags.mu.Unlock()
2113
2114 msgs := []AgentMessage{}
2115 if repoRoot == "" {
2116 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002117 }
2118
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002119 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07002120 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002121 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07002122 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002123 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002124 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07002125 }
2126 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002127 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07002128 }()
2129
Philip Zeyliger64f60462025-06-16 13:57:10 -07002130 // Compute diff stats from baseRef to HEAD when HEAD changes
2131 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
2132 // Log error but don't fail the entire operation
2133 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
2134 } else {
2135 // Set diff stats directly since we already hold the mutex
2136 ags.linesAdded = added
2137 ags.linesRemoved = removed
2138 }
2139
Earl Lee2e463fb2025-04-17 11:22:22 -07002140 // Get new commits. Because it's possible that the agent does rebases, fixups, and
2141 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
2142 // to the last 100 commits.
2143 var commits []*GitCommit
2144
2145 // Get commits since the initial commit
2146 // Format: <hash>\0<subject>\0<body>\0
2147 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
2148 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002149 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 -07002150 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07002151 output, err := cmd.Output()
2152 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002153 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07002154 }
2155
2156 // Parse git log output and filter out already seen commits
2157 parsedCommits := parseGitLog(string(output))
2158
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002159 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07002160
2161 // Filter out commits we've already seen
2162 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002163 if commit.Hash == sketch {
2164 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07002165 }
2166
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002167 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
2168 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07002169 continue
2170 }
2171
2172 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07002173 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07002174
2175 // Add to our list of new commits
2176 commits = append(commits, &commit)
2177 }
2178
Philip Zeyligerf2872992025-05-22 10:35:28 -07002179 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002180 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07002181 // 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 -07002182 sketchCommit = &GitCommit{}
2183 sketchCommit.Hash = sketch
2184 sketchCommit.Subject = "unknown"
2185 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07002186 }
2187
Earl Lee2e463fb2025-04-17 11:22:22 -07002188 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
2189 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
2190 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00002191
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002192 // 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 +00002193 var out []byte
2194 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002195 originalRetryNumber := ags.retryNumber
2196 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00002197 for retries := range 10 {
2198 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07002199 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002200 }
2201
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002202 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002203 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002204 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002205 out, err = cmd.CombinedOutput()
2206
2207 if err == nil {
2208 // Success! Break out of the retry loop
2209 break
2210 }
2211
2212 // Check if this is the "refusing to update checked out branch" error
2213 if !strings.Contains(string(out), "refusing to update checked out branch") {
2214 // This is a different error, so don't retry
2215 break
2216 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002217 }
2218
2219 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002220 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002221 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002222 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002223 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002224 if ags.retryNumber != originalRetryNumber {
2225 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002226 msgs = append(msgs, AgentMessage{
2227 Type: AutoMessageType,
2228 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002229 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 +00002230 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002231 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002232 }
2233 }
2234
2235 // If we found new commits, create a message
2236 if len(commits) > 0 {
2237 msg := AgentMessage{
2238 Type: CommitMessageType,
2239 Timestamp: time.Now(),
2240 Commits: commits,
2241 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002242 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002243 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002244 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002245}
2246
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002247func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002248 return strings.Map(func(r rune) rune {
2249 // lowercase
2250 if r >= 'A' && r <= 'Z' {
2251 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002252 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002253 // replace spaces with dashes
2254 if r == ' ' {
2255 return '-'
2256 }
2257 // allow alphanumerics and dashes
2258 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2259 return r
2260 }
2261 return -1
2262 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002263}
2264
2265// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2266// and returns an array of GitCommit structs.
2267func parseGitLog(output string) []GitCommit {
2268 var commits []GitCommit
2269
2270 // No output means no commits
2271 if len(output) == 0 {
2272 return commits
2273 }
2274
2275 // Split by NULL byte
2276 parts := strings.Split(output, "\x00")
2277
2278 // Process in triplets (hash, subject, body)
2279 for i := 0; i < len(parts); i++ {
2280 // Skip empty parts
2281 if parts[i] == "" {
2282 continue
2283 }
2284
2285 // This should be a hash
2286 hash := strings.TrimSpace(parts[i])
2287
2288 // Make sure we have at least a subject part available
2289 if i+1 >= len(parts) {
2290 break // No more parts available
2291 }
2292
2293 // Get the subject
2294 subject := strings.TrimSpace(parts[i+1])
2295
2296 // Get the body if available
2297 body := ""
2298 if i+2 < len(parts) {
2299 body = strings.TrimSpace(parts[i+2])
2300 }
2301
2302 // Skip to the next triplet
2303 i += 2
2304
2305 commits = append(commits, GitCommit{
2306 Hash: hash,
2307 Subject: subject,
2308 Body: body,
2309 })
2310 }
2311
2312 return commits
2313}
2314
2315func repoRoot(ctx context.Context, dir string) (string, error) {
2316 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2317 stderr := new(strings.Builder)
2318 cmd.Stderr = stderr
2319 cmd.Dir = dir
2320 out, err := cmd.Output()
2321 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002322 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002323 }
2324 return strings.TrimSpace(string(out)), nil
2325}
2326
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002327// upsertRemoteOrigin configures the origin remote to point to the given URL.
2328// If the origin remote exists, it updates the URL. If it doesn't exist, it adds it.
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002329//
2330// NOTE: Maybe we should use an "insteadOf" setting instead of changing the URL.
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002331func upsertRemoteOrigin(ctx context.Context, repoDir, remoteURL string) error {
2332 // Try to set the URL for existing origin remote
2333 cmd := exec.CommandContext(ctx, "git", "remote", "set-url", "origin", remoteURL)
2334 cmd.Dir = repoDir
2335 if _, err := cmd.CombinedOutput(); err == nil {
2336 // Success.
2337 return nil
2338 }
2339 // Origin doesn't exist; add it.
2340 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", remoteURL)
2341 cmd.Dir = repoDir
2342 if out, err := cmd.CombinedOutput(); err != nil {
2343 return fmt.Errorf("failed to add git remote origin: %s: %w", out, err)
2344 }
2345 return nil
2346}
2347
Earl Lee2e463fb2025-04-17 11:22:22 -07002348func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2349 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2350 stderr := new(strings.Builder)
2351 cmd.Stderr = stderr
2352 cmd.Dir = dir
2353 out, err := cmd.Output()
2354 if err != nil {
2355 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2356 }
2357 // TODO: validate that out is valid hex
2358 return strings.TrimSpace(string(out)), nil
2359}
2360
2361// isValidGitSHA validates if a string looks like a valid git SHA hash.
2362// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2363func isValidGitSHA(sha string) bool {
2364 // Git SHA must be a hexadecimal string with at least 4 characters
2365 if len(sha) < 4 || len(sha) > 40 {
2366 return false
2367 }
2368
2369 // Check if the string only contains hexadecimal characters
2370 for _, char := range sha {
2371 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2372 return false
2373 }
2374 }
2375
2376 return true
2377}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002378
Philip Zeyliger64f60462025-06-16 13:57:10 -07002379// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2380func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2381 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2382 cmd.Dir = repoRoot
2383 out, err := cmd.Output()
2384 if err != nil {
2385 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2386 }
2387
2388 var totalAdded, totalRemoved int
2389 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2390 for _, line := range lines {
2391 if line == "" {
2392 continue
2393 }
2394 parts := strings.Fields(line)
2395 if len(parts) < 2 {
2396 continue
2397 }
2398 // Format: <added>\t<removed>\t<filename>
2399 if added, err := strconv.Atoi(parts[0]); err == nil {
2400 totalAdded += added
2401 }
2402 if removed, err := strconv.Atoi(parts[1]); err == nil {
2403 totalRemoved += removed
2404 }
2405 }
2406
2407 return totalAdded, totalRemoved, nil
2408}
2409
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002410// systemPromptData contains the data used to render the system prompt template
2411type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002412 ClientGOOS string
2413 ClientGOARCH string
2414 WorkingDir string
2415 RepoRoot string
2416 InitialCommit string
2417 Codebase *onstart.Codebase
2418 UseSketchWIP bool
Philip Zeyligere67e3b62025-07-24 16:54:21 -07002419 InstallationNudge bool
David Crawshawc886ac52025-06-13 23:40:03 +00002420 Branch string
2421 SpecialInstruction string
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +00002422 Now string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002423}
2424
2425// renderSystemPrompt renders the system prompt template.
2426func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +00002427 nowFn := a.now
2428 if nowFn == nil {
2429 nowFn = time.Now
2430 }
2431 now := nowFn()
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002432 data := systemPromptData{
Philip Zeyligere67e3b62025-07-24 16:54:21 -07002433 ClientGOOS: a.config.ClientGOOS,
2434 ClientGOARCH: a.config.ClientGOARCH,
2435 WorkingDir: a.workingDir,
2436 RepoRoot: a.repoRoot,
2437 InitialCommit: a.SketchGitBase(),
2438 Codebase: a.codebase,
2439 UseSketchWIP: a.config.InDocker,
2440 InstallationNudge: a.config.InDocker,
Josh Bleecher Snyder9224eb02025-07-26 04:45:05 +00002441 Now: now.Format(time.DateOnly),
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002442 }
David Crawshawc886ac52025-06-13 23:40:03 +00002443 if now.Month() == time.September && now.Day() == 19 {
Josh Bleecher Snyder783ab312025-07-25 07:22:38 -07002444 data.SpecialInstruction = "Today is international talk like a pirate day. Occasionally drop a 🏴‍☠️ into the conversation (not code!), but subtly."
David Crawshawc886ac52025-06-13 23:40:03 +00002445 }
2446
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002447 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2448 if err != nil {
2449 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2450 }
2451 buf := new(strings.Builder)
2452 err = tmpl.Execute(buf, data)
2453 if err != nil {
2454 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2455 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002456 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002457 return buf.String()
2458}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002459
2460// StateTransitionIterator provides an iterator over state transitions.
2461type StateTransitionIterator interface {
2462 // Next blocks until a new state transition is available or context is done.
2463 // Returns nil if the context is cancelled.
2464 Next() *StateTransition
2465 // Close removes the listener and cleans up resources.
2466 Close()
2467}
2468
2469// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2470type StateTransitionIteratorImpl struct {
2471 agent *Agent
2472 ctx context.Context
2473 ch chan StateTransition
2474 unsubscribe func()
2475}
2476
2477// Next blocks until a new state transition is available or the context is cancelled.
2478func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2479 select {
2480 case <-s.ctx.Done():
2481 return nil
2482 case transition, ok := <-s.ch:
2483 if !ok {
2484 return nil
2485 }
2486 transitionCopy := transition
2487 return &transitionCopy
2488 }
2489}
2490
2491// Close removes the listener and cleans up resources.
2492func (s *StateTransitionIteratorImpl) Close() {
2493 if s.unsubscribe != nil {
2494 s.unsubscribe()
2495 s.unsubscribe = nil
2496 }
2497}
2498
2499// NewStateTransitionIterator returns an iterator that receives state transitions.
2500func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2501 a.mu.Lock()
2502 defer a.mu.Unlock()
2503
2504 // Create channel to receive state transitions
2505 ch := make(chan StateTransition, 10)
2506
2507 // Add a listener to the state machine
2508 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2509
2510 return &StateTransitionIteratorImpl{
2511 agent: a,
2512 ctx: ctx,
2513 ch: ch,
2514 unsubscribe: unsubscribe,
2515 }
2516}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002517
2518// setupGitHooks creates or updates git hooks in the specified working directory.
2519func setupGitHooks(workingDir string) error {
2520 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2521
2522 _, err := os.Stat(hooksDir)
2523 if os.IsNotExist(err) {
2524 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2525 }
2526 if err != nil {
2527 return fmt.Errorf("error checking git hooks directory: %w", err)
2528 }
2529
2530 // Define the post-commit hook content
2531 postCommitHook := `#!/bin/bash
2532echo "<post_commit_hook>"
2533echo "Please review this commit message and fix it if it is incorrect."
2534echo "This hook only echos the commit message; it does not modify it."
2535echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2536echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002537PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002538echo "</last_commit_message>"
2539echo "</post_commit_hook>"
2540`
2541
2542 // Define the prepare-commit-msg hook content
2543 prepareCommitMsgHook := `#!/bin/bash
2544# Add Co-Authored-By and Change-ID trailers to commit messages
2545# Check if these trailers already exist before adding them
2546
2547commit_file="$1"
2548COMMIT_SOURCE="$2"
2549
2550# Skip for merges, squashes, or when using a commit template
2551if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2552 [ "$COMMIT_SOURCE" = "squash" ]; then
2553 exit 0
2554fi
2555
2556commit_msg=$(cat "$commit_file")
2557
2558needs_co_author=true
2559needs_change_id=true
2560
2561# Check if commit message already has Co-Authored-By trailer
2562if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2563 needs_co_author=false
2564fi
2565
2566# Check if commit message already has Change-ID trailer
2567if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2568 needs_change_id=false
2569fi
2570
2571# Only modify if at least one trailer needs to be added
2572if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002573 # Ensure there's a proper blank line before trailers
2574 if [ -s "$commit_file" ]; then
2575 # Check if file ends with newline by reading last character
2576 last_char=$(tail -c 1 "$commit_file")
2577
2578 if [ "$last_char" != "" ]; then
2579 # File doesn't end with newline - add two newlines (complete line + blank line)
2580 echo "" >> "$commit_file"
2581 echo "" >> "$commit_file"
2582 else
2583 # File ends with newline - check if we already have a blank line
2584 last_line=$(tail -1 "$commit_file")
2585 if [ -n "$last_line" ]; then
2586 # Last line has content - add one newline for blank line
2587 echo "" >> "$commit_file"
2588 fi
2589 # If last line is empty, we already have a blank line - don't add anything
2590 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002591 fi
2592
2593 # Add trailers if needed
2594 if [ "$needs_co_author" = true ]; then
2595 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2596 fi
2597
2598 if [ "$needs_change_id" = true ]; then
2599 change_id=$(openssl rand -hex 8)
2600 echo "Change-ID: s${change_id}k" >> "$commit_file"
2601 fi
2602fi
2603`
2604
2605 // Update or create the post-commit hook
2606 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2607 if err != nil {
2608 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2609 }
2610
2611 // Update or create the prepare-commit-msg hook
2612 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2613 if err != nil {
2614 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2615 }
2616
2617 return nil
2618}
2619
2620// updateOrCreateHook creates a new hook file or updates an existing one
2621// by appending the new content if it doesn't already contain it.
2622func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2623 // Check if the hook already exists
2624 buf, err := os.ReadFile(hookPath)
2625 if os.IsNotExist(err) {
2626 // Hook doesn't exist, create it
2627 err = os.WriteFile(hookPath, []byte(content), 0o755)
2628 if err != nil {
2629 return fmt.Errorf("failed to create hook: %w", err)
2630 }
2631 return nil
2632 }
2633 if err != nil {
2634 return fmt.Errorf("error reading existing hook: %w", err)
2635 }
2636
2637 // Hook exists, check if our content is already in it by looking for a distinctive line
2638 code := string(buf)
2639 if strings.Contains(code, distinctiveLine) {
2640 // Already contains our content, nothing to do
2641 return nil
2642 }
2643
2644 // Append our content to the existing hook
2645 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2646 if err != nil {
2647 return fmt.Errorf("failed to open hook for appending: %w", err)
2648 }
2649 defer f.Close()
2650
2651 // Ensure there's a newline at the end of the existing content if needed
2652 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2653 _, err = f.WriteString("\n")
2654 if err != nil {
2655 return fmt.Errorf("failed to add newline to hook: %w", err)
2656 }
2657 }
2658
2659 // Add a separator before our content
2660 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2661 if err != nil {
2662 return fmt.Errorf("failed to append to hook: %w", err)
2663 }
2664
2665 return nil
2666}
Sean McCullough138ec242025-06-02 22:42:06 +00002667
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002668// configurePassthroughUpstream configures git remotes
2669// Adds an upstream remote pointing to the same as origin
2670// Sets the refspec for upstream and fetch such that both
2671// fetch the upstream's things into refs/remotes/upstream/foo
2672// The typical scenario is:
2673//
2674// github - laptop - sketch container
2675// "upstream" "origin"
2676func (a *Agent) configurePassthroughUpstream(ctx context.Context) error {
2677 // Get the origin remote URL
2678 cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
2679 cmd.Dir = a.workingDir
2680 originURLBytes, err := cmd.CombinedOutput()
2681 if err != nil {
2682 return fmt.Errorf("failed to get origin URL: %s: %w", originURLBytes, err)
2683 }
2684 originURL := strings.TrimSpace(string(originURLBytes))
2685
2686 // Check if upstream remote already exists
2687 cmd = exec.CommandContext(ctx, "git", "remote", "get-url", "upstream")
2688 cmd.Dir = a.workingDir
2689 if _, err := cmd.CombinedOutput(); err != nil {
2690 // upstream remote doesn't exist, create it
2691 cmd = exec.CommandContext(ctx, "git", "remote", "add", "upstream", originURL)
2692 cmd.Dir = a.workingDir
2693 if out, err := cmd.CombinedOutput(); err != nil {
2694 return fmt.Errorf("failed to add upstream remote: %s: %w", out, err)
2695 }
2696 slog.InfoContext(ctx, "added upstream remote", "url", originURL)
2697 } else {
2698 // upstream remote exists, update its URL
2699 cmd = exec.CommandContext(ctx, "git", "remote", "set-url", "upstream", originURL)
2700 cmd.Dir = a.workingDir
2701 if out, err := cmd.CombinedOutput(); err != nil {
2702 return fmt.Errorf("failed to set upstream remote URL: %s: %w", out, err)
2703 }
2704 slog.InfoContext(ctx, "updated upstream remote URL", "url", originURL)
2705 }
2706
2707 // Add the upstream refspec to the upstream remote
2708 cmd = exec.CommandContext(ctx, "git", "config", "remote.upstream.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2709 cmd.Dir = a.workingDir
2710 if out, err := cmd.CombinedOutput(); err != nil {
2711 return fmt.Errorf("failed to set upstream fetch refspec: %s: %w", out, err)
2712 }
2713
2714 // Add the same refspec to the origin remote
2715 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.origin.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2716 cmd.Dir = a.workingDir
2717 if out, err := cmd.CombinedOutput(); err != nil {
2718 return fmt.Errorf("failed to add upstream refspec to origin: %s: %w", out, err)
2719 }
2720
2721 slog.InfoContext(ctx, "configured passthrough upstream", "origin_url", originURL)
2722 return nil
2723}
2724
Philip Zeyliger0113be52025-06-07 23:53:41 +00002725// SkabandAddr returns the skaband address if configured
2726func (a *Agent) SkabandAddr() string {
2727 if a.config.SkabandClient != nil {
2728 return a.config.SkabandClient.Addr()
2729 }
2730 return ""
2731}