blob: 5fb2f3361fd463b7422536cbb67a41eba104555f [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
bankseanbdc68892025-07-28 17:28:13 -0700170
171 // ExternalMessage enqueues an external message to the agent and returns immediately.
172 ExternalMessage(ctx context.Context, msg ExternalMessage) error
Earl Lee2e463fb2025-04-17 11:22:22 -0700173}
174
175type CodingAgentMessageType string
176
177const (
bankseanbdc68892025-07-28 17:28:13 -0700178 UserMessageType CodingAgentMessageType = "user"
179 AgentMessageType CodingAgentMessageType = "agent"
180 ErrorMessageType CodingAgentMessageType = "error"
181 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
182 ToolUseMessageType CodingAgentMessageType = "tool"
183 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
184 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
185 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
186 PortMessageType CodingAgentMessageType = "port" // for port monitoring events
187 SlugMessageType CodingAgentMessageType = "slug" // for slug updates
188 ExternalMessageType CodingAgentMessageType = "external" // for external notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700189
190 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
191)
192
193type AgentMessage struct {
194 Type CodingAgentMessageType `json:"type"`
195 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
196 EndOfTurn bool `json:"end_of_turn"`
197
bankseanbdc68892025-07-28 17:28:13 -0700198 Content string `json:"content"`
199 ExternalMessage *ExternalMessage `json:"external_message,omitempty"`
200 ToolName string `json:"tool_name,omitempty"`
201 ToolInput string `json:"input,omitempty"`
202 ToolResult string `json:"tool_result,omitempty"`
203 ToolError bool `json:"tool_error,omitempty"`
204 ToolCallId string `json:"tool_call_id,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700205
206 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
207 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
208
Sean McCulloughd9f13372025-04-21 15:08:49 -0700209 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
210 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
211
Earl Lee2e463fb2025-04-17 11:22:22 -0700212 // Commits is a list of git commits for a commit message
213 Commits []*GitCommit `json:"commits,omitempty"`
214
215 Timestamp time.Time `json:"timestamp"`
216 ConversationID string `json:"conversation_id"`
217 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700218 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700219
220 // Message timing information
221 StartTime *time.Time `json:"start_time,omitempty"`
222 EndTime *time.Time `json:"end_time,omitempty"`
223 Elapsed *time.Duration `json:"elapsed,omitempty"`
224
225 // Turn duration - the time taken for a complete agent turn
226 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
227
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000228 // HideOutput indicates that this message should not be rendered in the UI.
229 // This is useful for subconversations that generate output that shouldn't be shown to the user.
230 HideOutput bool `json:"hide_output,omitempty"`
231
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700232 // TodoContent contains the agent's todo file content when it has changed
233 TodoContent *string `json:"todo_content,omitempty"`
234
Josh Bleecher Snyder3dd3e412025-07-22 20:32:03 -0700235 // Display contains content to be displayed to the user, set by tools
236 Display any `json:"display,omitempty"`
237
Earl Lee2e463fb2025-04-17 11:22:22 -0700238 Idx int `json:"idx"`
239}
240
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000241// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700242func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700243 if convo == nil {
244 m.ConversationID = ""
245 m.ParentConversationID = nil
246 return
247 }
248 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000249 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700250 if convo.Parent != nil {
251 m.ParentConversationID = &convo.Parent.ID
252 }
253}
254
Earl Lee2e463fb2025-04-17 11:22:22 -0700255// GitCommit represents a single git commit for a commit message
256type GitCommit struct {
257 Hash string `json:"hash"` // Full commit hash
258 Subject string `json:"subject"` // Commit subject line
259 Body string `json:"body"` // Full commit message body
260 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
261}
262
263// ToolCall represents a single tool call within an agent message
264type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700265 Name string `json:"name"`
266 Input string `json:"input"`
267 ToolCallId string `json:"tool_call_id"`
268 ResultMessage *AgentMessage `json:"result_message,omitempty"`
269 Args string `json:"args,omitempty"`
270 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700271}
272
273func (a *AgentMessage) Attr() slog.Attr {
274 var attrs []any = []any{
275 slog.String("type", string(a.Type)),
276 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700277 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700278 if a.EndOfTurn {
279 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
280 }
281 if a.Content != "" {
282 attrs = append(attrs, slog.String("content", a.Content))
283 }
284 if a.ToolName != "" {
285 attrs = append(attrs, slog.String("tool_name", a.ToolName))
286 }
287 if a.ToolInput != "" {
288 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
289 }
290 if a.Elapsed != nil {
291 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
292 }
293 if a.TurnDuration != nil {
294 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
295 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700296 if len(a.ToolResult) > 0 {
297 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700298 }
299 if a.ToolError {
300 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
301 }
302 if len(a.ToolCalls) > 0 {
303 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
304 for i, tc := range a.ToolCalls {
305 toolCallAttrs = append(toolCallAttrs, slog.Group(
306 fmt.Sprintf("tool_call_%d", i),
307 slog.String("name", tc.Name),
308 slog.String("input", tc.Input),
309 ))
310 }
311 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
312 }
313 if a.ConversationID != "" {
314 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
315 }
316 if a.ParentConversationID != nil {
317 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
318 }
319 if a.Usage != nil && !a.Usage.IsZero() {
320 attrs = append(attrs, a.Usage.Attr())
321 }
322 // TODO: timestamp, convo ids, idx?
323 return slog.Group("agent_message", attrs...)
324}
325
326func errorMessage(err error) AgentMessage {
327 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
328 if os.Getenv(("DEBUG")) == "1" {
329 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
330 }
331
332 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
333}
334
335func budgetMessage(err error) AgentMessage {
336 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
337}
338
339// ConvoInterface defines the interface for conversation interactions
340type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700341 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700342 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700343 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700344 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700345 SendMessage(message llm.Message) (*llm.Response, error)
346 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700347 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000348 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700349 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700350 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700351 SubConvoWithHistory() *conversation.Convo
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700352 DebugJSON() ([]byte, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700353}
354
Philip Zeyligerf2872992025-05-22 10:35:28 -0700355// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700356// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700357// any time we notice we need to.
358type AgentGitState struct {
359 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700360 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700361 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000362 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700363 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700364 slug string // Human-readable session identifier
365 retryNumber int // Number to append when branch conflicts occur
Philip Zeyliger64f60462025-06-16 13:57:10 -0700366 linesAdded int // Lines added from sketch-base to HEAD
367 linesRemoved int // Lines removed from sketch-base to HEAD
Philip Zeyligerf2872992025-05-22 10:35:28 -0700368}
369
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700370func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700371 ags.mu.Lock()
372 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700373 if ags.slug != slug {
374 ags.retryNumber = 0
375 }
376 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700377}
378
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700379func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700380 ags.mu.Lock()
381 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700382 return ags.slug
383}
384
385func (ags *AgentGitState) IncrementRetryNumber() {
386 ags.mu.Lock()
387 defer ags.mu.Unlock()
388 ags.retryNumber++
389}
390
Philip Zeyliger64f60462025-06-16 13:57:10 -0700391func (ags *AgentGitState) DiffStats() (int, int) {
392 ags.mu.Lock()
393 defer ags.mu.Unlock()
394 return ags.linesAdded, ags.linesRemoved
395}
396
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700397// HasSeenCommits returns true if any commits have been processed
398func (ags *AgentGitState) HasSeenCommits() bool {
399 ags.mu.Lock()
400 defer ags.mu.Unlock()
401 return len(ags.seenCommits) > 0
402}
403
404func (ags *AgentGitState) RetryNumber() int {
405 ags.mu.Lock()
406 defer ags.mu.Unlock()
407 return ags.retryNumber
408}
409
410func (ags *AgentGitState) BranchName(prefix string) string {
411 ags.mu.Lock()
412 defer ags.mu.Unlock()
413 return ags.branchNameLocked(prefix)
414}
415
416func (ags *AgentGitState) branchNameLocked(prefix string) string {
417 if ags.slug == "" {
418 return ""
419 }
420 if ags.retryNumber == 0 {
421 return prefix + ags.slug
422 }
423 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700424}
425
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000426func (ags *AgentGitState) Upstream() string {
427 ags.mu.Lock()
428 defer ags.mu.Unlock()
429 return ags.upstream
430}
431
Earl Lee2e463fb2025-04-17 11:22:22 -0700432type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700433 convo ConvoInterface
434 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700435 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700436 workingDir string
437 repoRoot string // workingDir may be a subdir of repoRoot
438 url string
439 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000440 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700441 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000442 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700443 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700444 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000445 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700446 // State machine to track agent state
447 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000448 // Outside information
449 outsideHostname string
450 outsideOS string
451 outsideWorkingDir string
Philip Zeyliger194bfa82025-06-24 06:03:06 -0700452 // MCP manager for handling MCP server connections
453 mcpManager *mcp.MCPManager
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000454 // Port monitor for tracking TCP ports
455 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700456
457 // Time when the current turn started (reset at the beginning of InnerLoop)
458 startOfTurn time.Time
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +0000459 now func() time.Time // override-able, defaults to time.Now
Earl Lee2e463fb2025-04-17 11:22:22 -0700460
461 // Inbox - for messages from the user to the agent.
462 // sent on by UserMessage
463 // . e.g. when user types into the chat textarea
464 // read from by GatherMessages
465 inbox chan string
466
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000467 // protects cancelTurn
468 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700469 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000470 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700471
472 // protects following
473 mu sync.Mutex
474
475 // Stores all messages for this agent
476 history []AgentMessage
477
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700478 // Iterators add themselves here when they're ready to be notified of new messages.
479 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700480
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000481 // Track outstanding LLM call IDs
482 outstandingLLMCalls map[string]struct{}
483
484 // Track outstanding tool calls by ID with their names
485 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700486}
487
bankseanbdc68892025-07-28 17:28:13 -0700488// ExternalMessage implements CodingAgent.
489// TODO: Debounce and/or coalesce these messages so they're less disruptive to the conversation.
490func (a *Agent) ExternalMessage(ctx context.Context, msg ExternalMessage) error {
491 agentMsg := AgentMessage{
492 Type: ExternalMessageType,
493 ExternalMessage: &msg,
494 }
495 a.pushToOutbox(ctx, agentMsg)
496 a.inbox <- msg.TextContent
497 return nil
498}
499
banksean5ab8fb82025-07-09 12:34:55 -0700500// TokenContextWindow implements CodingAgent.
501func (a *Agent) TokenContextWindow() int {
502 return a.config.Service.TokenContextWindow()
503}
504
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000505// ModelName returns the name of the model the agent is using.
506func (a *Agent) ModelName() string {
507 return a.config.Model
508}
509
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700510// GetConvo returns the conversation interface for debugging purposes.
511func (a *Agent) GetConvo() ConvoInterface {
512 return a.convo
513}
514
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700515// NewIterator implements CodingAgent.
516func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
517 a.mu.Lock()
518 defer a.mu.Unlock()
519
520 return &MessageIteratorImpl{
521 agent: a,
522 ctx: ctx,
523 nextMessageIdx: nextMessageIdx,
524 ch: make(chan *AgentMessage, 100),
525 }
526}
527
528type MessageIteratorImpl struct {
529 agent *Agent
530 ctx context.Context
531 nextMessageIdx int
532 ch chan *AgentMessage
533 subscribed bool
534}
535
536func (m *MessageIteratorImpl) Close() {
537 m.agent.mu.Lock()
538 defer m.agent.mu.Unlock()
539 // Delete ourselves from the subscribers list
540 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
541 return x == m.ch
542 })
543 close(m.ch)
544}
545
546func (m *MessageIteratorImpl) Next() *AgentMessage {
547 // We avoid subscription at creation to let ourselves catch up to "current state"
548 // before subscribing.
549 if !m.subscribed {
550 m.agent.mu.Lock()
551 if m.nextMessageIdx < len(m.agent.history) {
552 msg := &m.agent.history[m.nextMessageIdx]
553 m.nextMessageIdx++
554 m.agent.mu.Unlock()
555 return msg
556 }
557 // The next message doesn't exist yet, so let's subscribe
558 m.agent.subscribers = append(m.agent.subscribers, m.ch)
559 m.subscribed = true
560 m.agent.mu.Unlock()
561 }
562
563 for {
564 select {
565 case <-m.ctx.Done():
566 m.agent.mu.Lock()
567 // Delete ourselves from the subscribers list
568 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
569 return x == m.ch
570 })
571 m.subscribed = false
572 m.agent.mu.Unlock()
573 return nil
574 case msg, ok := <-m.ch:
575 if !ok {
576 // Close may have been called
577 return nil
578 }
579 if msg.Idx == m.nextMessageIdx {
580 m.nextMessageIdx++
581 return msg
582 }
583 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
584 panic("out of order message")
585 }
586 }
587}
588
Sean McCulloughd9d45812025-04-30 16:53:41 -0700589// Assert that Agent satisfies the CodingAgent interface.
590var _ CodingAgent = &Agent{}
591
592// StateName implements CodingAgent.
593func (a *Agent) CurrentStateName() string {
594 if a.stateMachine == nil {
595 return ""
596 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000597 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700598}
599
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700600// CurrentTodoContent returns the current todo list data as JSON.
601// It returns an empty string if no todos exist.
602func (a *Agent) CurrentTodoContent() string {
603 todoPath := claudetool.TodoFilePath(a.config.SessionID)
604 content, err := os.ReadFile(todoPath)
605 if err != nil {
606 return ""
607 }
608 return string(content)
609}
610
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700611// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
612func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
613 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.
614
615IMPORTANT: 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.
616
617Please create a detailed summary that includes:
618
6191. **User's Request**: What did the user originally ask me to do? What was their goal?
620
6212. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
622
6233. **Key Technical Decisions**: What important technical choices were made during our work and why?
624
6254. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
626
6275. **Next Steps**: What still needs to be done to complete the user's request?
628
6296. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
630
631Focus 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.
632
633Reply with ONLY the summary content - no meta-commentary about creating the summary.`
634
635 userMessage := llm.UserStringMessage(msg)
636 // Use a subconversation with history to get the summary
637 // TODO: We don't have any tools here, so we should have enough tokens
638 // to capture a summary, but we may need to modify the history (e.g., remove
639 // TODO data) to save on some tokens.
640 convo := a.convo.SubConvoWithHistory()
641
642 // Modify the system prompt to provide context about the original task
643 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000644 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 -0700645
646Your 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.
647
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000648Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700649
650 resp, err := convo.SendMessage(userMessage)
651 if err != nil {
652 a.pushToOutbox(ctx, errorMessage(err))
653 return "", err
654 }
655 textContent := collectTextContent(resp)
656
657 // Restore original system prompt (though this subconvo will be discarded)
658 convo.SystemPrompt = originalSystemPrompt
659
660 return textContent, nil
661}
662
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000663// dumpMessageHistoryToTmp dumps the agent's entire message history to /tmp as JSON
664// and returns the filename
665func (a *Agent) dumpMessageHistoryToTmp(ctx context.Context) (string, error) {
666 // Create a filename based on session ID and timestamp
667 timestamp := time.Now().Format("20060102-150405")
668 filename := fmt.Sprintf("/tmp/sketch-messages-%s-%s.json", a.config.SessionID, timestamp)
669
670 // Marshal the entire message history to JSON
671 jsonData, err := json.MarshalIndent(a.history, "", " ")
672 if err != nil {
673 return "", fmt.Errorf("failed to marshal message history: %w", err)
674 }
675
676 // Write to file
Autoformatter3ad8c8d2025-07-15 21:05:23 +0000677 if err := os.WriteFile(filename, jsonData, 0o644); err != nil {
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000678 return "", fmt.Errorf("failed to write message history to %s: %w", filename, err)
679 }
680
681 slog.InfoContext(ctx, "Dumped message history to file", "filename", filename, "message_count", len(a.history))
682 return filename, nil
683}
684
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700685// CompactConversation compacts the current conversation by generating a summary
686// and restarting the conversation with that summary as the initial context
687func (a *Agent) CompactConversation(ctx context.Context) error {
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000688 // Dump the entire message history to /tmp as JSON before compacting
689 dumpFile, err := a.dumpMessageHistoryToTmp(ctx)
690 if err != nil {
691 slog.WarnContext(ctx, "Failed to dump message history to /tmp", "error", err)
692 // Continue with compaction even if dump fails
693 }
694
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700695 summary, err := a.generateConversationSummary(ctx)
696 if err != nil {
697 return fmt.Errorf("failed to generate conversation summary: %w", err)
698 }
699
700 a.mu.Lock()
701
702 // Get usage information before resetting conversation
703 lastUsage := a.convo.LastUsage()
704 contextWindow := a.config.Service.TokenContextWindow()
705 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
706
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000707 // Preserve cumulative usage across compaction
708 cumulativeUsage := a.convo.CumulativeUsage()
709
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700710 // Reset conversation state but keep all other state (git, working dir, etc.)
711 a.firstMessageIndex = len(a.history)
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000712 a.convo = a.initConvoWithUsage(&cumulativeUsage)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700713
714 a.mu.Unlock()
715
716 // Create informative compaction message with token details
717 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
718 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
719 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
720
721 a.pushToOutbox(ctx, AgentMessage{
722 Type: CompactMessageType,
723 Content: compactionMsg,
724 })
725
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000726 // Create the message content with dump file information if available
727 var messageContent string
728 if dumpFile != "" {
729 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)
730 } else {
731 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)
732 }
733
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700734 a.pushToOutbox(ctx, AgentMessage{
735 Type: UserMessageType,
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000736 Content: messageContent,
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700737 })
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000738 a.inbox <- messageContent
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700739
740 return nil
741}
742
Earl Lee2e463fb2025-04-17 11:22:22 -0700743func (a *Agent) URL() string { return a.url }
744
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000745// GetPorts returns the cached list of open TCP ports.
746func (a *Agent) GetPorts() []portlist.Port {
747 if a.portMonitor == nil {
748 return nil
749 }
750 return a.portMonitor.GetPorts()
751}
752
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000753// BranchName returns the git branch name for the conversation.
754func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700755 return a.gitState.BranchName(a.config.BranchPrefix)
756}
757
758// Slug returns the slug identifier for this conversation.
759func (a *Agent) Slug() string {
760 return a.gitState.Slug()
761}
762
763// IncrementRetryNumber increments the retry number for branch naming conflicts
764func (a *Agent) IncrementRetryNumber() {
765 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000766}
767
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000768// OutstandingLLMCallCount returns the number of outstanding LLM calls.
769func (a *Agent) OutstandingLLMCallCount() int {
770 a.mu.Lock()
771 defer a.mu.Unlock()
772 return len(a.outstandingLLMCalls)
773}
774
775// OutstandingToolCalls returns the names of outstanding tool calls.
776func (a *Agent) OutstandingToolCalls() []string {
777 a.mu.Lock()
778 defer a.mu.Unlock()
779
780 tools := make([]string, 0, len(a.outstandingToolCalls))
781 for _, toolName := range a.outstandingToolCalls {
782 tools = append(tools, toolName)
783 }
784 return tools
785}
786
Earl Lee2e463fb2025-04-17 11:22:22 -0700787// OS returns the operating system of the client.
788func (a *Agent) OS() string {
789 return a.config.ClientGOOS
790}
791
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000792func (a *Agent) SessionID() string {
793 return a.config.SessionID
794}
795
philip.zeyliger8773e682025-06-11 21:36:21 -0700796// SSHConnectionString returns the SSH connection string for the container.
797func (a *Agent) SSHConnectionString() string {
798 return a.config.SSHConnectionString
799}
800
Philip Zeyliger18532b22025-04-23 21:11:46 +0000801// OutsideOS returns the operating system of the outside system.
802func (a *Agent) OutsideOS() string {
803 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000804}
805
Philip Zeyliger18532b22025-04-23 21:11:46 +0000806// OutsideHostname returns the hostname of the outside system.
807func (a *Agent) OutsideHostname() string {
808 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000809}
810
Philip Zeyliger18532b22025-04-23 21:11:46 +0000811// OutsideWorkingDir returns the working directory on the outside system.
812func (a *Agent) OutsideWorkingDir() string {
813 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000814}
815
816// GitOrigin returns the URL of the git remote 'origin' if it exists.
817func (a *Agent) GitOrigin() string {
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +0000818 return a.config.OriginalGitOrigin
Philip Zeyligerd1402952025-04-23 03:54:37 +0000819}
820
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700821// PassthroughUpstream returns whether passthrough upstream is enabled.
822func (a *Agent) PassthroughUpstream() bool {
823 return a.config.PassthroughUpstream
824}
825
bankseancad67b02025-06-27 21:57:05 +0000826// GitUsername returns the git user name from the agent config.
827func (a *Agent) GitUsername() string {
828 return a.config.GitUsername
829}
830
Philip Zeyliger64f60462025-06-16 13:57:10 -0700831// DiffStats returns the number of lines added and removed from sketch-base to HEAD
832func (a *Agent) DiffStats() (int, int) {
833 return a.gitState.DiffStats()
834}
835
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000836func (a *Agent) OpenBrowser(url string) {
837 if !a.IsInContainer() {
838 browser.Open(url)
839 return
840 }
841 // We're in Docker, need to send a request to the Git server
842 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700843 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000844 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700845 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000846 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700847 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000848 return
849 }
850 defer resp.Body.Close()
851 if resp.StatusCode == http.StatusOK {
852 return
853 }
854 body, _ := io.ReadAll(resp.Body)
855 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
856}
857
Sean McCullough96b60dd2025-04-30 09:49:10 -0700858// CurrentState returns the current state of the agent's state machine.
859func (a *Agent) CurrentState() State {
860 return a.stateMachine.CurrentState()
861}
862
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700863func (a *Agent) IsInContainer() bool {
864 return a.config.InDocker
865}
866
867func (a *Agent) FirstMessageIndex() int {
868 a.mu.Lock()
869 defer a.mu.Unlock()
870 return a.firstMessageIndex
871}
872
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700873// SetSlug sets a human-readable identifier for the conversation.
874func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700875 a.mu.Lock()
876 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700877
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700878 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000879 convo, ok := a.convo.(*conversation.Convo)
880 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700881 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000882 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700883}
884
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000885// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700886func (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 +0000887 // Track the tool call
888 a.mu.Lock()
889 a.outstandingToolCalls[id] = toolName
890 a.mu.Unlock()
891}
892
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700893// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
894// If there's only one element in the array and it's a text type, it returns that text directly.
895// It also processes nested ToolResult arrays recursively.
896func contentToString(contents []llm.Content) string {
897 if len(contents) == 0 {
898 return ""
899 }
900
901 // If there's only one element and it's a text type, return it directly
902 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
903 return contents[0].Text
904 }
905
906 // Otherwise, concatenate all text content
907 var result strings.Builder
908 for _, content := range contents {
909 if content.Type == llm.ContentTypeText {
910 result.WriteString(content.Text)
911 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
912 // Recursively process nested tool results
913 result.WriteString(contentToString(content.ToolResult))
914 }
915 }
916
917 return result.String()
918}
919
Earl Lee2e463fb2025-04-17 11:22:22 -0700920// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700921func (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 +0000922 // Remove the tool call from outstanding calls
923 a.mu.Lock()
924 delete(a.outstandingToolCalls, toolID)
925 a.mu.Unlock()
926
Earl Lee2e463fb2025-04-17 11:22:22 -0700927 m := AgentMessage{
928 Type: ToolUseMessageType,
929 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700930 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700931 ToolError: content.ToolError,
932 ToolName: toolName,
933 ToolInput: string(toolInput),
934 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700935 StartTime: content.ToolUseStartTime,
936 EndTime: content.ToolUseEndTime,
Josh Bleecher Snyder3dd3e412025-07-22 20:32:03 -0700937 Display: content.Display,
Earl Lee2e463fb2025-04-17 11:22:22 -0700938 }
939
940 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700941 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
942 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700943 m.Elapsed = &elapsed
944 }
945
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700946 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700947 a.pushToOutbox(ctx, m)
948}
949
950// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700951func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000952 a.mu.Lock()
953 defer a.mu.Unlock()
954 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700955 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
956}
957
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700958// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700959// that need to be displayed (as well as tool calls that we send along when
960// they're done). (It would be reasonable to also mention tool calls when they're
961// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700962func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000963 // Remove the LLM call from outstanding calls
964 a.mu.Lock()
965 delete(a.outstandingLLMCalls, id)
966 a.mu.Unlock()
967
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700968 if resp == nil {
969 // LLM API call failed
970 m := AgentMessage{
971 Type: ErrorMessageType,
972 Content: "API call failed, type 'continue' to try again",
973 }
974 m.SetConvo(convo)
975 a.pushToOutbox(ctx, m)
976 return
977 }
978
Earl Lee2e463fb2025-04-17 11:22:22 -0700979 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700980 if convo.Parent == nil { // subconvos never end the turn
981 switch resp.StopReason {
982 case llm.StopReasonToolUse:
983 // Check whether any of the tool calls are for tools that should end the turn
984 ToolSearch:
985 for _, part := range resp.Content {
986 if part.Type != llm.ContentTypeToolUse {
987 continue
988 }
Sean McCullough021557a2025-05-05 23:20:53 +0000989 // Find the tool by name
990 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700991 if tool.Name == part.ToolName {
992 endOfTurn = tool.EndsTurn
993 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000994 }
995 }
Sean McCullough021557a2025-05-05 23:20:53 +0000996 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700997 default:
998 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +0000999 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001000 }
1001 m := AgentMessage{
1002 Type: AgentMessageType,
1003 Content: collectTextContent(resp),
1004 EndOfTurn: endOfTurn,
1005 Usage: &resp.Usage,
1006 StartTime: resp.StartTime,
1007 EndTime: resp.EndTime,
1008 }
1009
1010 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001011 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -07001012 var toolCalls []ToolCall
1013 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001014 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -07001015 toolCalls = append(toolCalls, ToolCall{
1016 Name: part.ToolName,
1017 Input: string(part.ToolInput),
1018 ToolCallId: part.ID,
1019 })
1020 }
1021 }
1022 m.ToolCalls = toolCalls
1023 }
1024
1025 // Calculate the elapsed time if both start and end times are set
1026 if resp.StartTime != nil && resp.EndTime != nil {
1027 elapsed := resp.EndTime.Sub(*resp.StartTime)
1028 m.Elapsed = &elapsed
1029 }
1030
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -07001031 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -07001032 a.pushToOutbox(ctx, m)
1033}
1034
1035// WorkingDir implements CodingAgent.
1036func (a *Agent) WorkingDir() string {
1037 return a.workingDir
1038}
1039
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001040// RepoRoot returns the git repository root directory.
1041func (a *Agent) RepoRoot() string {
1042 return a.repoRoot
1043}
1044
Earl Lee2e463fb2025-04-17 11:22:22 -07001045// MessageCount implements CodingAgent.
1046func (a *Agent) MessageCount() int {
1047 a.mu.Lock()
1048 defer a.mu.Unlock()
1049 return len(a.history)
1050}
1051
1052// Messages implements CodingAgent.
1053func (a *Agent) Messages(start int, end int) []AgentMessage {
1054 a.mu.Lock()
1055 defer a.mu.Unlock()
1056 return slices.Clone(a.history[start:end])
1057}
1058
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001059// ShouldCompact checks if the conversation should be compacted based on token usage
1060func (a *Agent) ShouldCompact() bool {
1061 // Get the threshold from environment variable, default to 0.94 (94%)
1062 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
1063 // and a little bit of buffer.)
1064 thresholdRatio := 0.94
1065 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
1066 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
1067 thresholdRatio = parsed
1068 }
1069 }
1070
1071 // Get the most recent usage to check current context size
1072 lastUsage := a.convo.LastUsage()
1073
1074 if lastUsage.InputTokens == 0 {
1075 // No API calls made yet
1076 return false
1077 }
1078
1079 // Calculate the current context size from the last API call
1080 // This includes all tokens that were part of the input context:
1081 // - Input tokens (user messages, system prompt, conversation history)
1082 // - Cache read tokens (cached parts of the context)
1083 // - Cache creation tokens (new parts being cached)
1084 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
1085
1086 // Get the service's token context window
1087 service := a.config.Service
1088 contextWindow := service.TokenContextWindow()
1089
1090 // Calculate threshold
1091 threshold := uint64(float64(contextWindow) * thresholdRatio)
1092
1093 // Check if we've exceeded the threshold
1094 return currentContextSize >= threshold
1095}
1096
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001097func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -07001098 return a.originalBudget
1099}
1100
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001101// Upstream returns the upstream branch for git work
1102func (a *Agent) Upstream() string {
1103 return a.gitState.Upstream()
1104}
1105
Earl Lee2e463fb2025-04-17 11:22:22 -07001106// AgentConfig contains configuration for creating a new Agent.
1107type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001108 Context context.Context
1109 Service llm.Service
1110 Budget conversation.Budget
1111 GitUsername string
1112 GitEmail string
1113 SessionID string
1114 ClientGOOS string
1115 ClientGOARCH string
1116 InDocker bool
1117 OneShot bool
1118 WorkingDir string
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +00001119 // Model is the name of the LLM model being used
1120 Model string
Philip Zeyliger18532b22025-04-23 21:11:46 +00001121 // Outside information
1122 OutsideHostname string
1123 OutsideOS string
1124 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001125
1126 // Outtie's HTTP to, e.g., open a browser
1127 OutsideHTTP string
1128 // Outtie's Git server
1129 GitRemoteAddr string
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001130 // Original git origin URL from host repository, if any
1131 OriginalGitOrigin string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001132 // Upstream branch for git work
1133 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001134 // Commit to checkout from Outtie
1135 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001136 // Prefix for git branches created by sketch
1137 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001138 // LinkToGitHub enables GitHub branch linking in UI
1139 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001140 // SSH connection string for connecting to the container
1141 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001142 // Skaband client for session history (optional)
1143 SkabandClient *skabandclient.SkabandClient
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001144 // MCP server configurations
1145 MCPServers []string
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001146 // Timeout configuration for bash tool
1147 BashTimeouts *claudetool.Timeouts
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001148 // PassthroughUpstream configures upstream remote for passthrough to innie
1149 PassthroughUpstream bool
Josh Bleecher Snyder1e551672025-07-30 03:16:54 +00001150 // FetchOnLaunch enables git fetch during initialization
1151 FetchOnLaunch bool
Earl Lee2e463fb2025-04-17 11:22:22 -07001152}
1153
1154// NewAgent creates a new Agent.
1155// It is not usable until Init() is called.
1156func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001157 // Set default branch prefix if not specified
1158 if config.BranchPrefix == "" {
1159 config.BranchPrefix = "sketch/"
1160 }
1161
Earl Lee2e463fb2025-04-17 11:22:22 -07001162 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001163 config: config,
1164 ready: make(chan struct{}),
1165 inbox: make(chan string, 100),
1166 subscribers: make([]chan *AgentMessage, 0),
1167 startedAt: time.Now(),
1168 originalBudget: config.Budget,
1169 gitState: AgentGitState{
1170 seenCommits: make(map[string]bool),
1171 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001172 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001173 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001174 outsideHostname: config.OutsideHostname,
1175 outsideOS: config.OutsideOS,
1176 outsideWorkingDir: config.OutsideWorkingDir,
1177 outstandingLLMCalls: make(map[string]struct{}),
1178 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001179 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001180 workingDir: config.WorkingDir,
1181 outsideHTTP: config.OutsideHTTP,
Philip Zeyligerda623b52025-07-04 01:12:38 +00001182
1183 mcpManager: mcp.NewMCPManager(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001184 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001185
1186 // Initialize port monitor with 5-second interval
1187 agent.portMonitor = NewPortMonitor(agent, 5*time.Second)
1188
Earl Lee2e463fb2025-04-17 11:22:22 -07001189 return agent
1190}
1191
1192type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001193 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001194
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001195 InDocker bool
1196 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001197}
1198
1199func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001200 if a.convo != nil {
1201 return fmt.Errorf("Agent.Init: already initialized")
1202 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001203 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001204 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001205
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001206 // If a remote + commit was specified, clone it.
1207 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001208 if _, err := os.Stat("/app/.git"); err != nil {
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00001209 slog.InfoContext(ctx, "cloning git repo", "commit", a.config.Commit)
1210 // TODO: --reference-if-able instead?
1211 cmd := exec.CommandContext(ctx, "git", "clone", "--reference", "/git-ref", a.gitState.gitRemoteAddr, "/app")
1212 if out, err := cmd.CombinedOutput(); err != nil {
1213 return fmt.Errorf("failed to clone repository from %s: %s: %w", a.gitState.gitRemoteAddr, out, err)
1214 }
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001215 }
1216 }
1217
1218 if a.workingDir != "" {
1219 err := os.Chdir(a.workingDir)
1220 if err != nil {
1221 return fmt.Errorf("failed to change working directory to %s: %w", a.workingDir, err)
1222 }
1223 }
1224
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001225 if !ini.NoGit {
Philip Zeyligeraccf37c2025-07-18 07:29:19 -07001226 if a.gitState.gitRemoteAddr != "" {
1227 if err := upsertRemoteOrigin(ctx, "/app", a.gitState.gitRemoteAddr); err != nil {
1228 return err
1229 }
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001230 }
Philip Zeyligere1c8b7b2025-07-03 14:50:26 -07001231
1232 // Configure git user settings
1233 if a.config.GitEmail != "" {
1234 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.email", a.config.GitEmail)
1235 cmd.Dir = a.workingDir
1236 if out, err := cmd.CombinedOutput(); err != nil {
1237 return fmt.Errorf("git config --global user.email: %s: %v", out, err)
1238 }
1239 }
1240 if a.config.GitUsername != "" {
1241 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.name", a.config.GitUsername)
1242 cmd.Dir = a.workingDir
1243 if out, err := cmd.CombinedOutput(); err != nil {
1244 return fmt.Errorf("git config --global user.name: %s: %v", out, err)
1245 }
1246 }
1247 // Configure git http.postBuffer
1248 cmd := exec.CommandContext(ctx, "git", "config", "--global", "http.postBuffer", "524288000")
1249 cmd.Dir = a.workingDir
1250 if out, err := cmd.CombinedOutput(); err != nil {
1251 return fmt.Errorf("git config --global http.postBuffer: %s: %v", out, err)
1252 }
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001253
1254 // Configure passthrough upstream if enabled
1255 if a.config.PassthroughUpstream {
1256 if err := a.configurePassthroughUpstream(ctx); err != nil {
1257 return fmt.Errorf("failed to configure passthrough upstream: %w", err)
1258 }
1259 }
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001260 }
1261
Philip Zeyligerf2872992025-05-22 10:35:28 -07001262 // If a commit was specified, we fetch and reset to it.
1263 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Josh Bleecher Snyder1e551672025-07-30 03:16:54 +00001264 if a.config.FetchOnLaunch {
1265 slog.InfoContext(ctx, "updating git repo", "commit", a.config.Commit)
1266 cmd := exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
1267 cmd.Dir = a.workingDir
1268 if out, err := cmd.CombinedOutput(); err != nil {
1269 return fmt.Errorf("git fetch: %s: %w", out, err)
1270 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001271 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001272 // The -B resets the branch if it already exists (or creates it if it doesn't)
Josh Bleecher Snyder1e551672025-07-30 03:16:54 +00001273 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001274 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001275 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1276 // Remove git hooks if they exist and retry
1277 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001278 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001279 if _, statErr := os.Stat(hookPath); statErr == nil {
1280 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1281 slog.String("error", err.Error()),
1282 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001283 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001284 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1285 }
1286
1287 // Retry the checkout operation
Philip Zeyliger1417b692025-06-12 11:07:04 -07001288 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001289 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001290 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001291 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 +01001292 }
1293 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001294 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001295 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001296 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001297 } else if a.IsInContainer() {
1298 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1299 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1300 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1301 cmd.Dir = a.workingDir
1302 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1303 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1304 }
1305 } else {
1306 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001307 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001308
1309 if ini.HostAddr != "" {
1310 a.url = "http://" + ini.HostAddr
1311 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001312
1313 if !ini.NoGit {
1314 repoRoot, err := repoRoot(ctx, a.workingDir)
1315 if err != nil {
1316 return fmt.Errorf("repoRoot: %w", err)
1317 }
1318 a.repoRoot = repoRoot
1319
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001320 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001321 if err := setupGitHooks(a.repoRoot); err != nil {
1322 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1323 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001324 }
1325
philz24613202025-07-15 20:56:21 -07001326 // Check if we have any commits, and if not, create an empty initial commit
1327 cmd := exec.CommandContext(ctx, "git", "rev-list", "--all", "--count")
1328 cmd.Dir = repoRoot
1329 countOut, err := cmd.CombinedOutput()
1330 if err != nil {
1331 return fmt.Errorf("git rev-list --all --count: %s: %w", countOut, err)
1332 }
1333 commitCount := strings.TrimSpace(string(countOut))
1334 if commitCount == "0" {
1335 slog.Info("No commits found, creating empty initial commit")
1336 cmd = exec.CommandContext(ctx, "git", "commit", "--allow-empty", "-m", "Initial empty commit")
1337 cmd.Dir = repoRoot
1338 if commitOut, err := cmd.CombinedOutput(); err != nil {
1339 return fmt.Errorf("git commit --allow-empty: %s: %w", commitOut, err)
1340 }
1341 }
1342
1343 cmd = exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
Philip Zeyliger49edc922025-05-14 09:45:45 -07001344 cmd.Dir = repoRoot
1345 if out, err := cmd.CombinedOutput(); err != nil {
1346 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1347 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001348
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001349 slog.Info("running codebase analysis")
1350 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1351 if err != nil {
1352 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001353 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001354 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001355
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001356 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001357 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001358 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001359 }
1360 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001361
Earl Lee2e463fb2025-04-17 11:22:22 -07001362 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001363 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001364 a.convo = a.initConvo()
1365 close(a.ready)
1366 return nil
1367}
1368
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001369//go:embed agent_system_prompt.txt
1370var agentSystemPrompt string
1371
Earl Lee2e463fb2025-04-17 11:22:22 -07001372// initConvo initializes the conversation.
1373// It must not be called until all agent fields are initialized,
1374// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001375func (a *Agent) initConvo() *conversation.Convo {
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001376 return a.initConvoWithUsage(nil)
1377}
1378
1379// initConvoWithUsage initializes the conversation with optional preserved usage.
1380func (a *Agent) initConvoWithUsage(usage *conversation.CumulativeUsage) *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001381 ctx := a.config.Context
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001382 convo := conversation.New(ctx, a.config.Service, usage)
Earl Lee2e463fb2025-04-17 11:22:22 -07001383 convo.PromptCaching = true
1384 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001385 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001386 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001387
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001388 bashTool := &claudetool.BashTool{
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001389 EnableJITInstall: claudetool.EnableBashToolJITInstall,
1390 Timeouts: a.config.BashTimeouts,
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -07001391 Pwd: a.workingDir,
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001392 }
Josh Bleecher Snyder04f16a52025-07-30 11:46:25 -07001393 patchTool := &claudetool.PatchTool{
1394 Callback: a.patchCallback,
Josh Bleecher Snyder136b66d2025-07-30 11:48:58 -07001395 Pwd: a.workingDir,
Josh Bleecher Snyder04f16a52025-07-30 11:46:25 -07001396 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001397
Earl Lee2e463fb2025-04-17 11:22:22 -07001398 // Register all tools with the conversation
1399 // When adding, removing, or modifying tools here, double-check that the termui tool display
1400 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001401
1402 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001403 _, supportsScreenshots := a.config.Service.(*ant.Service)
1404 var bTools []*llm.Tool
1405 var browserCleanup func()
1406
1407 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1408 // Add cleanup function to context cancel
1409 go func() {
1410 <-a.config.Context.Done()
1411 browserCleanup()
1412 }()
1413 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001414
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001415 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd64bc912025-07-24 11:42:33 -07001416 bashTool.Tool(),
1417 claudetool.Keyword,
Josh Bleecher Snyder04f16a52025-07-30 11:46:25 -07001418 patchTool.Tool(),
Josh Bleecher Snyderd64bc912025-07-24 11:42:33 -07001419 claudetool.Think,
1420 claudetool.TodoRead,
1421 claudetool.TodoWrite,
1422 makeDoneTool(a.codereview),
1423 a.codereview.Tool(),
1424 claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001425 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001426 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001427
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001428 // Add MCP tools if configured
1429 if len(a.config.MCPServers) > 0 {
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001430
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001431 slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers))
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001432 serverConfigs, parseErrors := mcp.ParseServerConfigs(ctx, a.config.MCPServers)
1433
1434 // Replace any headers with value _sketch_public_key_ and _sketch_session_id_ with those values.
1435 for i := range serverConfigs {
1436 if serverConfigs[i].Headers != nil {
1437 for key, value := range serverConfigs[i].Headers {
Philip Zeyligerf2814ea2025-06-30 10:16:50 -07001438 // Replace env placeholders. E.g., "env:FOO" becomes os.Getenv("FOO")
1439 if strings.HasPrefix(value, "env:") {
1440 serverConfigs[i].Headers[key] = os.Getenv(value[4:])
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001441 }
1442 }
1443 }
1444 }
Philip Zeyligerc540df72025-07-25 09:21:56 -07001445 mcpConnections, mcpErrors := a.mcpManager.ConnectToServerConfigs(ctx, serverConfigs, mcp.DefaultMCPConnectionTimeout, parseErrors)
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001446
1447 if len(mcpErrors) > 0 {
1448 for _, err := range mcpErrors {
1449 slog.ErrorContext(ctx, "MCP connection error", "error", err)
1450 // Send agent message about MCP connection failures
1451 a.pushToOutbox(ctx, AgentMessage{
1452 Type: ErrorMessageType,
1453 Content: fmt.Sprintf("MCP server connection failed: %v", err),
1454 })
1455 }
1456 }
1457
1458 if len(mcpConnections) > 0 {
1459 // Add tools from all successful connections
1460 totalTools := 0
1461 for _, connection := range mcpConnections {
1462 convo.Tools = append(convo.Tools, connection.Tools...)
1463 totalTools += len(connection.Tools)
1464 // Log tools per server using structured data
1465 slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames)
1466 }
1467 slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools)
1468 } else {
1469 slog.InfoContext(ctx, "No MCP tools available after connection attempts")
1470 }
1471 }
1472
Earl Lee2e463fb2025-04-17 11:22:22 -07001473 convo.Listener = a
1474 return convo
1475}
1476
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001477// branchExists reports whether branchName exists, either locally or in well-known remotes.
1478func branchExists(dir, branchName string) bool {
1479 refs := []string{
1480 "refs/heads/",
1481 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001482 }
1483 for _, ref := range refs {
1484 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1485 cmd.Dir = dir
1486 if cmd.Run() == nil { // exit code 0 means branch exists
1487 return true
1488 }
1489 }
1490 return false
1491}
1492
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001493func soleText(contents []llm.Content) (string, error) {
1494 if len(contents) != 1 {
1495 return "", fmt.Errorf("multiple contents %v", contents)
1496 }
1497 content := contents[0]
1498 if content.Type != llm.ContentTypeText || content.Text == "" {
1499 return "", fmt.Errorf("bad content %v", content)
1500 }
1501 return strings.TrimSpace(content.Text), nil
1502}
1503
1504// autoGenerateSlug automatically generates a slug based on the first user input
1505func (a *Agent) autoGenerateSlug(ctx context.Context, userContents []llm.Content) error {
1506 userText, err := soleText(userContents)
1507 if err != nil {
1508 return err
1509 }
1510 if userText == "" {
1511 return fmt.Errorf("set-slug: empty text content")
1512 }
1513
1514 // Create a subconversation without history for slug generation
1515 convo, ok := a.convo.(*conversation.Convo)
1516 if !ok {
1517 // In test environments, the conversation might be a mock interface
1518 // Skip slug generation in this case
1519 return fmt.Errorf("set-slug: can't make a subconvo (mock convo?)")
1520 }
1521
1522 // Loop until we find an acceptable slug
1523 var unavailableSlugs []string
1524 for {
1525 if len(unavailableSlugs) > 10 {
1526 // sanity check to prevent infinite loops
1527 return fmt.Errorf("set-slug: failed to construct a new slug after %d attempts", len(unavailableSlugs))
Earl Lee2e463fb2025-04-17 11:22:22 -07001528 }
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001529 subConvo := convo.SubConvo()
1530 subConvo.Hidden = true
1531
1532 // Prompt for slug generation
1533 prompt := `You are a slug generator for Sketch, an agentic coding environment.
1534The user's prompt will be in <user-prompt> tags. Any unavailable slugs will be listed in <unavailable-slug> tags.
1535Generate a 2-3 word alphanumeric hyphenated slug in imperative tense that captures the essence of their coding task.
1536Respond with only the slug.`
1537
1538 buf := new(strings.Builder)
1539 buf.WriteString("<slug-request>")
1540 if len(unavailableSlugs) > 0 {
1541 buf.WriteString("<unavailable-slugs>")
1542 }
1543 for _, x := range unavailableSlugs {
1544 buf.WriteString("<unavailable-slug>")
1545 buf.WriteString(x)
1546 buf.WriteString("</unavailable-slug>")
1547 }
1548 if len(unavailableSlugs) > 0 {
1549 buf.WriteString("</unavailable-slugs>")
1550 }
1551 buf.WriteString("<user-prompt>")
1552 buf.WriteString(userText)
1553 buf.WriteString("</user-prompt>")
1554 buf.WriteString("</slug-request>")
1555
1556 fullPrompt := prompt + "\n" + buf.String()
1557 userMessage := llm.UserStringMessage(fullPrompt)
1558
1559 resp, err := subConvo.SendMessage(userMessage)
1560 if err != nil {
1561 return fmt.Errorf("failed to generate slug: %w", err)
1562 }
1563
1564 // Extract the slug from the response
1565 slugText, err := soleText(resp.Content)
1566 if err != nil {
1567 return err
1568 }
1569 if slugText == "" {
1570 return fmt.Errorf("empty slug generated")
1571 }
1572
1573 // Clean and validate the slug
1574 slug := cleanSlugName(slugText)
1575 if slug == "" {
1576 return fmt.Errorf("slug could not be cleaned: %q", slugText)
1577 }
1578
1579 // Check if branch already exists using the same logic as the original set-slug tool
1580 a.SetSlug(slug) // Set slug first so BranchName() works correctly
1581 if branchExists(a.workingDir, a.BranchName()) {
1582 // try again
1583 unavailableSlugs = append(unavailableSlugs, slug)
1584 continue
1585 }
1586
1587 // Success! Slug is available and already set
1588 return nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001589 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001590}
1591
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001592// patchCallback is the agent's patch tool callback.
1593// It warms the codereview cache in the background.
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001594func (a *Agent) patchCallback(input claudetool.PatchInput, output llm.ToolOut) llm.ToolOut {
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001595 if a.codereview != nil {
1596 a.codereview.WarmTestCache(input.Path)
1597 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001598 return output
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001599}
1600
Earl Lee2e463fb2025-04-17 11:22:22 -07001601func (a *Agent) Ready() <-chan struct{} {
1602 return a.ready
1603}
1604
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001605// BranchPrefix returns the configured branch prefix
1606func (a *Agent) BranchPrefix() string {
1607 return a.config.BranchPrefix
1608}
1609
philip.zeyliger6d3de482025-06-10 19:38:14 -07001610// LinkToGitHub returns whether GitHub branch linking is enabled
1611func (a *Agent) LinkToGitHub() bool {
1612 return a.config.LinkToGitHub
1613}
1614
Earl Lee2e463fb2025-04-17 11:22:22 -07001615func (a *Agent) UserMessage(ctx context.Context, msg string) {
1616 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1617 a.inbox <- msg
1618}
1619
Earl Lee2e463fb2025-04-17 11:22:22 -07001620func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1621 return a.convo.CancelToolUse(toolUseID, cause)
1622}
1623
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001624func (a *Agent) CancelTurn(cause error) {
1625 a.cancelTurnMu.Lock()
1626 defer a.cancelTurnMu.Unlock()
1627 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001628 // Force state transition to cancelled state
1629 ctx := a.config.Context
1630 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001631 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001632 }
1633}
1634
1635func (a *Agent) Loop(ctxOuter context.Context) {
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001636 // Start port monitoring
1637 if a.portMonitor != nil && a.IsInContainer() {
1638 if err := a.portMonitor.Start(ctxOuter); err != nil {
1639 slog.WarnContext(ctxOuter, "Failed to start port monitor", "error", err)
1640 } else {
1641 slog.InfoContext(ctxOuter, "Port monitor started")
1642 }
1643 }
1644
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001645 // Set up cleanup when context is done
1646 defer func() {
1647 if a.mcpManager != nil {
1648 a.mcpManager.Close()
1649 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001650 if a.portMonitor != nil && a.IsInContainer() {
1651 a.portMonitor.Stop()
1652 }
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001653 }()
1654
Earl Lee2e463fb2025-04-17 11:22:22 -07001655 for {
1656 select {
1657 case <-ctxOuter.Done():
1658 return
1659 default:
1660 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001661 a.cancelTurnMu.Lock()
1662 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001663 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001664 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001665 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001666 a.cancelTurn = cancel
1667 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001668 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1669 if err != nil {
1670 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1671 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001672 cancel(nil)
1673 }
1674 }
1675}
1676
1677func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1678 if m.Timestamp.IsZero() {
1679 m.Timestamp = time.Now()
1680 }
1681
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001682 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1683 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1684 m.Content = m.ToolResult
1685 }
1686
Earl Lee2e463fb2025-04-17 11:22:22 -07001687 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1688 if m.EndOfTurn && m.Type == AgentMessageType {
1689 turnDuration := time.Since(a.startOfTurn)
1690 m.TurnDuration = &turnDuration
1691 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1692 }
1693
Earl Lee2e463fb2025-04-17 11:22:22 -07001694 a.mu.Lock()
1695 defer a.mu.Unlock()
1696 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001697 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001698 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001699
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001700 // Notify all subscribers
1701 for _, ch := range a.subscribers {
1702 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001703 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001704}
1705
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001706func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1707 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001708 if block {
1709 select {
1710 case <-ctx.Done():
1711 return m, ctx.Err()
1712 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001713 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001714 }
1715 }
1716 for {
1717 select {
1718 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001719 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001720 default:
1721 return m, nil
1722 }
1723 }
1724}
1725
Sean McCullough885a16a2025-04-30 02:49:25 +00001726// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001727func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001728 // Reset the start of turn time
1729 a.startOfTurn = time.Now()
1730
Sean McCullough96b60dd2025-04-30 09:49:10 -07001731 // Transition to waiting for user input state
1732 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1733
Sean McCullough885a16a2025-04-30 02:49:25 +00001734 // Process initial user message
1735 initialResp, err := a.processUserMessage(ctx)
1736 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001737 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001738 return err
1739 }
1740
1741 // Handle edge case where both initialResp and err are nil
1742 if initialResp == nil {
1743 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001744 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1745
Sean McCullough9f4b8082025-04-30 17:34:07 +00001746 a.pushToOutbox(ctx, errorMessage(err))
1747 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001748 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001749
Earl Lee2e463fb2025-04-17 11:22:22 -07001750 // We do this as we go, but let's also do it at the end of the turn
1751 defer func() {
1752 if _, err := a.handleGitCommits(ctx); err != nil {
1753 // Just log the error, don't stop execution
1754 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1755 }
1756 }()
1757
Sean McCullougha1e0e492025-05-01 10:51:08 -07001758 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001759 resp := initialResp
1760 for {
1761 // Check if we are over budget
1762 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001763 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001764 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001765 }
1766
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001767 // Check if we should compact the conversation
1768 if a.ShouldCompact() {
1769 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1770 if err := a.CompactConversation(ctx); err != nil {
1771 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1772 return err
1773 }
1774 // After compaction, end this turn and start fresh
1775 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1776 return nil
1777 }
1778
Sean McCullough885a16a2025-04-30 02:49:25 +00001779 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001780 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001781 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001782 break
1783 }
1784
Sean McCullough96b60dd2025-04-30 09:49:10 -07001785 // Transition to tool use requested state
1786 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1787
Sean McCullough885a16a2025-04-30 02:49:25 +00001788 // Handle tool execution
1789 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1790 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001791 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001792 }
1793
Sean McCullougha1e0e492025-05-01 10:51:08 -07001794 if toolResp == nil {
1795 return fmt.Errorf("cannot continue conversation with a nil tool response")
1796 }
1797
Sean McCullough885a16a2025-04-30 02:49:25 +00001798 // Set the response for the next iteration
1799 resp = toolResp
1800 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001801
1802 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001803}
1804
1805// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001806func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001807 // Wait for at least one message from the user
1808 msgs, err := a.GatherMessages(ctx, true)
1809 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001810 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001811 return nil, err
1812 }
1813
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001814 // Auto-generate slug if this is the first user input and no slug is set
1815 if a.Slug() == "" {
1816 if err := a.autoGenerateSlug(ctx, msgs); err != nil {
1817 // NB: it is possible that autoGenerateSlug set the slug during the process
1818 // of trying to generate a slug.
1819 // The fact that it returned an error means that we cannot use that slug.
1820 slog.WarnContext(ctx, "Failed to auto-generate slug", "error", err)
1821 // use the session id instead. ugly, but we need a slug, and this will be unique.
1822 a.SetSlug(a.SessionID())
1823 }
1824 // Notify termui of the final slug (only emitted once, after slug is determined)
1825 a.pushToOutbox(ctx, AgentMessage{
1826 Type: SlugMessageType,
1827 Content: a.Slug(),
1828 })
1829 }
1830
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001831 userMessage := llm.Message{
1832 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001833 Content: msgs,
1834 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001835
Sean McCullough96b60dd2025-04-30 09:49:10 -07001836 // Transition to sending to LLM state
1837 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1838
Sean McCullough885a16a2025-04-30 02:49:25 +00001839 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001840 resp, err := a.convo.SendMessage(userMessage)
1841 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001842 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001843 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001844 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001845 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001846
Sean McCullough96b60dd2025-04-30 09:49:10 -07001847 // Transition to processing LLM response state
1848 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1849
Sean McCullough885a16a2025-04-30 02:49:25 +00001850 return resp, nil
1851}
1852
1853// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001854func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1855 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001856 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001857 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001858
Sean McCullough96b60dd2025-04-30 09:49:10 -07001859 // Transition to checking for cancellation state
1860 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1861
Sean McCullough885a16a2025-04-30 02:49:25 +00001862 // Check if the operation was cancelled by the user
1863 select {
1864 case <-ctx.Done():
1865 // Don't actually run any of the tools, but rather build a response
1866 // for each tool_use message letting the LLM know that user canceled it.
1867 var err error
1868 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001869 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001870 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001871 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001872 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001873 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001874 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001875 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001876 // Transition to running tool state
1877 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1878
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001879 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001880 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001881 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001882
1883 // Execute the tools
1884 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001885 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001886 if ctx.Err() != nil { // e.g. the user canceled the operation
1887 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001888 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001889 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001890 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001891 a.pushToOutbox(ctx, errorMessage(err))
1892 }
1893 }
1894
1895 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001896 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001897 autoqualityMessages := a.processGitChanges(ctx)
1898
1899 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001900 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001901 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001902 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001903 return false, nil
1904 }
1905
1906 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001907 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1908 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001909}
1910
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001911// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001912func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001913 // Check for git commits
1914 _, err := a.handleGitCommits(ctx)
1915 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001916 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001917 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001918 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001919 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001920}
1921
1922// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1923// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001924func (a *Agent) processGitChanges(ctx context.Context) []string {
1925 // Check for git commits after tool execution
1926 newCommits, err := a.handleGitCommits(ctx)
1927 if err != nil {
1928 // Just log the error, don't stop execution
1929 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1930 return nil
1931 }
1932
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001933 // Run mechanical checks if there was exactly one new commit.
1934 if len(newCommits) != 1 {
1935 return nil
1936 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001937 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001938 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1939 msg := a.codereview.RunMechanicalChecks(ctx)
1940 if msg != "" {
1941 a.pushToOutbox(ctx, AgentMessage{
1942 Type: AutoMessageType,
1943 Content: msg,
1944 Timestamp: time.Now(),
1945 })
1946 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001947 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001948
1949 return autoqualityMessages
1950}
1951
1952// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001953func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001954 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001955 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001956 msgs, err := a.GatherMessages(ctx, false)
1957 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001958 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001959 return false, nil
1960 }
1961
1962 // Inject any auto-generated messages from quality checks
1963 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001964 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001965 }
1966
1967 // Handle cancellation by appending a message about it
1968 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001969 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001970 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001971 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001972 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1973 } else if err := a.convo.OverBudget(); err != nil {
1974 // Handle budget issues by appending a message about it
1975 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 -07001976 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001977 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1978 }
1979
1980 // Combine tool results with user messages
1981 results = append(results, msgs...)
1982
1983 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001984 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001985 resp, err := a.convo.SendMessage(llm.Message{
1986 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001987 Content: results,
1988 })
1989 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001990 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001991 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1992 return true, nil // Return true to continue the conversation, but with no response
1993 }
1994
Sean McCullough96b60dd2025-04-30 09:49:10 -07001995 // Transition back to processing LLM response
1996 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1997
Sean McCullough885a16a2025-04-30 02:49:25 +00001998 if cancelled {
1999 return false, nil
2000 }
2001
2002 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07002003}
2004
2005func (a *Agent) overBudget(ctx context.Context) error {
2006 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07002007 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07002008 m := budgetMessage(err)
2009 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07002010 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07002011 a.convo.ResetBudget(a.originalBudget)
2012 return err
2013 }
2014 return nil
2015}
2016
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002017func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07002018 // Collect all text content
2019 var allText strings.Builder
2020 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002021 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07002022 if allText.Len() > 0 {
2023 allText.WriteString("\n\n")
2024 }
2025 allText.WriteString(content.Text)
2026 }
2027 }
2028 return allText.String()
2029}
2030
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002031func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07002032 a.mu.Lock()
2033 defer a.mu.Unlock()
2034 return a.convo.CumulativeUsage()
2035}
2036
Earl Lee2e463fb2025-04-17 11:22:22 -07002037// Diff returns a unified diff of changes made since the agent was instantiated.
2038func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07002039 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07002040 return "", fmt.Errorf("no initial commit reference available")
2041 }
2042
2043 // Find the repository root
2044 ctx := context.Background()
2045
2046 // If a specific commit hash is provided, show just that commit's changes
2047 if commit != nil && *commit != "" {
2048 // Validate that the commit looks like a valid git SHA
2049 if !isValidGitSHA(*commit) {
2050 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
2051 }
2052
2053 // Get the diff for just this commit
2054 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
2055 cmd.Dir = a.repoRoot
2056 output, err := cmd.CombinedOutput()
2057 if err != nil {
2058 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
2059 }
2060 return string(output), nil
2061 }
2062
2063 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07002064 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07002065 cmd.Dir = a.repoRoot
2066 output, err := cmd.CombinedOutput()
2067 if err != nil {
2068 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
2069 }
2070
2071 return string(output), nil
2072}
2073
Philip Zeyliger49edc922025-05-14 09:45:45 -07002074// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
2075// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
2076func (a *Agent) SketchGitBaseRef() string {
2077 if a.IsInContainer() {
2078 return "sketch-base"
2079 } else {
2080 return "sketch-base-" + a.SessionID()
2081 }
2082}
2083
2084// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
2085func (a *Agent) SketchGitBase() string {
2086 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
2087 cmd.Dir = a.repoRoot
2088 output, err := cmd.CombinedOutput()
2089 if err != nil {
2090 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
2091 return "HEAD"
2092 }
2093 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002094}
2095
Pokey Rule7a113622025-05-12 10:58:45 +01002096// removeGitHooks removes the Git hooks directory from the repository
2097func removeGitHooks(_ context.Context, repoPath string) error {
2098 hooksDir := filepath.Join(repoPath, ".git", "hooks")
2099
2100 // Check if hooks directory exists
2101 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
2102 // Directory doesn't exist, nothing to do
2103 return nil
2104 }
2105
2106 // Remove the hooks directory
2107 err := os.RemoveAll(hooksDir)
2108 if err != nil {
2109 return fmt.Errorf("failed to remove git hooks directory: %w", err)
2110 }
2111
2112 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00002113 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01002114 if err != nil {
2115 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
2116 }
2117
2118 return nil
2119}
2120
Philip Zeyligerf2872992025-05-22 10:35:28 -07002121func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002122 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002123 for _, msg := range msgs {
2124 a.pushToOutbox(ctx, msg)
2125 }
2126 return commits, error
2127}
2128
Earl Lee2e463fb2025-04-17 11:22:22 -07002129// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002130// under docker, new HEADs are pushed to a branch according to the slug.
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002131func (ags *AgentGitState) handleGitCommits(ctx context.Context, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002132 ags.mu.Lock()
2133 defer ags.mu.Unlock()
2134
2135 msgs := []AgentMessage{}
2136 if repoRoot == "" {
2137 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002138 }
2139
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002140 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07002141 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002142 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07002143 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002144 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002145 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07002146 }
2147 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002148 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07002149 }()
2150
Philip Zeyliger64f60462025-06-16 13:57:10 -07002151 // Compute diff stats from baseRef to HEAD when HEAD changes
2152 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
2153 // Log error but don't fail the entire operation
2154 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
2155 } else {
2156 // Set diff stats directly since we already hold the mutex
2157 ags.linesAdded = added
2158 ags.linesRemoved = removed
2159 }
2160
Earl Lee2e463fb2025-04-17 11:22:22 -07002161 // Get new commits. Because it's possible that the agent does rebases, fixups, and
2162 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
2163 // to the last 100 commits.
2164 var commits []*GitCommit
2165
2166 // Get commits since the initial commit
2167 // Format: <hash>\0<subject>\0<body>\0
2168 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
2169 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002170 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 -07002171 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07002172 output, err := cmd.Output()
2173 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002174 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07002175 }
2176
2177 // Parse git log output and filter out already seen commits
2178 parsedCommits := parseGitLog(string(output))
2179
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002180 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07002181
2182 // Filter out commits we've already seen
2183 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002184 if commit.Hash == sketch {
2185 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07002186 }
2187
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002188 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
2189 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07002190 continue
2191 }
2192
2193 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07002194 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07002195
2196 // Add to our list of new commits
2197 commits = append(commits, &commit)
2198 }
2199
Philip Zeyligerf2872992025-05-22 10:35:28 -07002200 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002201 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07002202 // 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 -07002203 sketchCommit = &GitCommit{}
2204 sketchCommit.Hash = sketch
2205 sketchCommit.Subject = "unknown"
2206 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07002207 }
2208
Earl Lee2e463fb2025-04-17 11:22:22 -07002209 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
2210 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
2211 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00002212
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002213 // 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 +00002214 var out []byte
2215 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002216 originalRetryNumber := ags.retryNumber
2217 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00002218 for retries := range 10 {
2219 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07002220 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002221 }
2222
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002223 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002224 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002225 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002226 out, err = cmd.CombinedOutput()
2227
2228 if err == nil {
2229 // Success! Break out of the retry loop
2230 break
2231 }
2232
2233 // Check if this is the "refusing to update checked out branch" error
2234 if !strings.Contains(string(out), "refusing to update checked out branch") {
2235 // This is a different error, so don't retry
2236 break
2237 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002238 }
2239
2240 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002241 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002242 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002243 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002244 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002245 if ags.retryNumber != originalRetryNumber {
2246 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002247 msgs = append(msgs, AgentMessage{
2248 Type: AutoMessageType,
2249 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002250 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 +00002251 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002252 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002253 }
2254 }
2255
2256 // If we found new commits, create a message
2257 if len(commits) > 0 {
2258 msg := AgentMessage{
2259 Type: CommitMessageType,
2260 Timestamp: time.Now(),
2261 Commits: commits,
2262 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002263 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002264 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002265 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002266}
2267
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002268func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002269 return strings.Map(func(r rune) rune {
2270 // lowercase
2271 if r >= 'A' && r <= 'Z' {
2272 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002273 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002274 // replace spaces with dashes
2275 if r == ' ' {
2276 return '-'
2277 }
2278 // allow alphanumerics and dashes
2279 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2280 return r
2281 }
2282 return -1
2283 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002284}
2285
2286// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2287// and returns an array of GitCommit structs.
2288func parseGitLog(output string) []GitCommit {
2289 var commits []GitCommit
2290
2291 // No output means no commits
2292 if len(output) == 0 {
2293 return commits
2294 }
2295
2296 // Split by NULL byte
2297 parts := strings.Split(output, "\x00")
2298
2299 // Process in triplets (hash, subject, body)
2300 for i := 0; i < len(parts); i++ {
2301 // Skip empty parts
2302 if parts[i] == "" {
2303 continue
2304 }
2305
2306 // This should be a hash
2307 hash := strings.TrimSpace(parts[i])
2308
2309 // Make sure we have at least a subject part available
2310 if i+1 >= len(parts) {
2311 break // No more parts available
2312 }
2313
2314 // Get the subject
2315 subject := strings.TrimSpace(parts[i+1])
2316
2317 // Get the body if available
2318 body := ""
2319 if i+2 < len(parts) {
2320 body = strings.TrimSpace(parts[i+2])
2321 }
2322
2323 // Skip to the next triplet
2324 i += 2
2325
2326 commits = append(commits, GitCommit{
2327 Hash: hash,
2328 Subject: subject,
2329 Body: body,
2330 })
2331 }
2332
2333 return commits
2334}
2335
2336func repoRoot(ctx context.Context, dir string) (string, error) {
2337 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2338 stderr := new(strings.Builder)
2339 cmd.Stderr = stderr
2340 cmd.Dir = dir
2341 out, err := cmd.Output()
2342 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002343 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002344 }
2345 return strings.TrimSpace(string(out)), nil
2346}
2347
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002348// upsertRemoteOrigin configures the origin remote to point to the given URL.
2349// If the origin remote exists, it updates the URL. If it doesn't exist, it adds it.
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002350//
2351// NOTE: Maybe we should use an "insteadOf" setting instead of changing the URL.
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002352func upsertRemoteOrigin(ctx context.Context, repoDir, remoteURL string) error {
2353 // Try to set the URL for existing origin remote
2354 cmd := exec.CommandContext(ctx, "git", "remote", "set-url", "origin", remoteURL)
2355 cmd.Dir = repoDir
2356 if _, err := cmd.CombinedOutput(); err == nil {
2357 // Success.
2358 return nil
2359 }
2360 // Origin doesn't exist; add it.
2361 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", remoteURL)
2362 cmd.Dir = repoDir
2363 if out, err := cmd.CombinedOutput(); err != nil {
2364 return fmt.Errorf("failed to add git remote origin: %s: %w", out, err)
2365 }
2366 return nil
2367}
2368
Earl Lee2e463fb2025-04-17 11:22:22 -07002369func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2370 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2371 stderr := new(strings.Builder)
2372 cmd.Stderr = stderr
2373 cmd.Dir = dir
2374 out, err := cmd.Output()
2375 if err != nil {
2376 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2377 }
2378 // TODO: validate that out is valid hex
2379 return strings.TrimSpace(string(out)), nil
2380}
2381
2382// isValidGitSHA validates if a string looks like a valid git SHA hash.
2383// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2384func isValidGitSHA(sha string) bool {
2385 // Git SHA must be a hexadecimal string with at least 4 characters
2386 if len(sha) < 4 || len(sha) > 40 {
2387 return false
2388 }
2389
2390 // Check if the string only contains hexadecimal characters
2391 for _, char := range sha {
2392 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2393 return false
2394 }
2395 }
2396
2397 return true
2398}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002399
Philip Zeyliger64f60462025-06-16 13:57:10 -07002400// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2401func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2402 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2403 cmd.Dir = repoRoot
2404 out, err := cmd.Output()
2405 if err != nil {
2406 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2407 }
2408
2409 var totalAdded, totalRemoved int
2410 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2411 for _, line := range lines {
2412 if line == "" {
2413 continue
2414 }
2415 parts := strings.Fields(line)
2416 if len(parts) < 2 {
2417 continue
2418 }
2419 // Format: <added>\t<removed>\t<filename>
2420 if added, err := strconv.Atoi(parts[0]); err == nil {
2421 totalAdded += added
2422 }
2423 if removed, err := strconv.Atoi(parts[1]); err == nil {
2424 totalRemoved += removed
2425 }
2426 }
2427
2428 return totalAdded, totalRemoved, nil
2429}
2430
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002431// systemPromptData contains the data used to render the system prompt template
2432type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002433 ClientGOOS string
2434 ClientGOARCH string
2435 WorkingDir string
2436 RepoRoot string
2437 InitialCommit string
2438 Codebase *onstart.Codebase
2439 UseSketchWIP bool
Philip Zeyligere67e3b62025-07-24 16:54:21 -07002440 InstallationNudge bool
David Crawshawc886ac52025-06-13 23:40:03 +00002441 Branch string
2442 SpecialInstruction string
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +00002443 Now string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002444}
2445
2446// renderSystemPrompt renders the system prompt template.
2447func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +00002448 nowFn := a.now
2449 if nowFn == nil {
2450 nowFn = time.Now
2451 }
2452 now := nowFn()
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002453 data := systemPromptData{
Philip Zeyligere67e3b62025-07-24 16:54:21 -07002454 ClientGOOS: a.config.ClientGOOS,
2455 ClientGOARCH: a.config.ClientGOARCH,
2456 WorkingDir: a.workingDir,
2457 RepoRoot: a.repoRoot,
2458 InitialCommit: a.SketchGitBase(),
2459 Codebase: a.codebase,
2460 UseSketchWIP: a.config.InDocker,
2461 InstallationNudge: a.config.InDocker,
Josh Bleecher Snyder9224eb02025-07-26 04:45:05 +00002462 Now: now.Format(time.DateOnly),
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002463 }
David Crawshawc886ac52025-06-13 23:40:03 +00002464 if now.Month() == time.September && now.Day() == 19 {
Josh Bleecher Snyder783ab312025-07-25 07:22:38 -07002465 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 +00002466 }
2467
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002468 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2469 if err != nil {
2470 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2471 }
2472 buf := new(strings.Builder)
2473 err = tmpl.Execute(buf, data)
2474 if err != nil {
2475 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2476 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002477 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002478 return buf.String()
2479}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002480
2481// StateTransitionIterator provides an iterator over state transitions.
2482type StateTransitionIterator interface {
2483 // Next blocks until a new state transition is available or context is done.
2484 // Returns nil if the context is cancelled.
2485 Next() *StateTransition
2486 // Close removes the listener and cleans up resources.
2487 Close()
2488}
2489
2490// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2491type StateTransitionIteratorImpl struct {
2492 agent *Agent
2493 ctx context.Context
2494 ch chan StateTransition
2495 unsubscribe func()
2496}
2497
2498// Next blocks until a new state transition is available or the context is cancelled.
2499func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2500 select {
2501 case <-s.ctx.Done():
2502 return nil
2503 case transition, ok := <-s.ch:
2504 if !ok {
2505 return nil
2506 }
2507 transitionCopy := transition
2508 return &transitionCopy
2509 }
2510}
2511
2512// Close removes the listener and cleans up resources.
2513func (s *StateTransitionIteratorImpl) Close() {
2514 if s.unsubscribe != nil {
2515 s.unsubscribe()
2516 s.unsubscribe = nil
2517 }
2518}
2519
2520// NewStateTransitionIterator returns an iterator that receives state transitions.
2521func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2522 a.mu.Lock()
2523 defer a.mu.Unlock()
2524
2525 // Create channel to receive state transitions
2526 ch := make(chan StateTransition, 10)
2527
2528 // Add a listener to the state machine
2529 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2530
2531 return &StateTransitionIteratorImpl{
2532 agent: a,
2533 ctx: ctx,
2534 ch: ch,
2535 unsubscribe: unsubscribe,
2536 }
2537}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002538
2539// setupGitHooks creates or updates git hooks in the specified working directory.
2540func setupGitHooks(workingDir string) error {
2541 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2542
2543 _, err := os.Stat(hooksDir)
2544 if os.IsNotExist(err) {
2545 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2546 }
2547 if err != nil {
2548 return fmt.Errorf("error checking git hooks directory: %w", err)
2549 }
2550
2551 // Define the post-commit hook content
2552 postCommitHook := `#!/bin/bash
2553echo "<post_commit_hook>"
2554echo "Please review this commit message and fix it if it is incorrect."
2555echo "This hook only echos the commit message; it does not modify it."
2556echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2557echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002558PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002559echo "</last_commit_message>"
2560echo "</post_commit_hook>"
2561`
2562
2563 // Define the prepare-commit-msg hook content
2564 prepareCommitMsgHook := `#!/bin/bash
2565# Add Co-Authored-By and Change-ID trailers to commit messages
2566# Check if these trailers already exist before adding them
2567
2568commit_file="$1"
2569COMMIT_SOURCE="$2"
2570
2571# Skip for merges, squashes, or when using a commit template
2572if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2573 [ "$COMMIT_SOURCE" = "squash" ]; then
2574 exit 0
2575fi
2576
2577commit_msg=$(cat "$commit_file")
2578
2579needs_co_author=true
2580needs_change_id=true
2581
2582# Check if commit message already has Co-Authored-By trailer
2583if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2584 needs_co_author=false
2585fi
2586
2587# Check if commit message already has Change-ID trailer
2588if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2589 needs_change_id=false
2590fi
2591
2592# Only modify if at least one trailer needs to be added
2593if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002594 # Ensure there's a proper blank line before trailers
2595 if [ -s "$commit_file" ]; then
2596 # Check if file ends with newline by reading last character
2597 last_char=$(tail -c 1 "$commit_file")
2598
2599 if [ "$last_char" != "" ]; then
2600 # File doesn't end with newline - add two newlines (complete line + blank line)
2601 echo "" >> "$commit_file"
2602 echo "" >> "$commit_file"
2603 else
2604 # File ends with newline - check if we already have a blank line
2605 last_line=$(tail -1 "$commit_file")
2606 if [ -n "$last_line" ]; then
2607 # Last line has content - add one newline for blank line
2608 echo "" >> "$commit_file"
2609 fi
2610 # If last line is empty, we already have a blank line - don't add anything
2611 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002612 fi
2613
2614 # Add trailers if needed
2615 if [ "$needs_co_author" = true ]; then
2616 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2617 fi
2618
2619 if [ "$needs_change_id" = true ]; then
2620 change_id=$(openssl rand -hex 8)
2621 echo "Change-ID: s${change_id}k" >> "$commit_file"
2622 fi
2623fi
2624`
2625
2626 // Update or create the post-commit hook
2627 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2628 if err != nil {
2629 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2630 }
2631
2632 // Update or create the prepare-commit-msg hook
2633 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2634 if err != nil {
2635 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2636 }
2637
2638 return nil
2639}
2640
2641// updateOrCreateHook creates a new hook file or updates an existing one
2642// by appending the new content if it doesn't already contain it.
2643func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2644 // Check if the hook already exists
2645 buf, err := os.ReadFile(hookPath)
2646 if os.IsNotExist(err) {
2647 // Hook doesn't exist, create it
2648 err = os.WriteFile(hookPath, []byte(content), 0o755)
2649 if err != nil {
2650 return fmt.Errorf("failed to create hook: %w", err)
2651 }
2652 return nil
2653 }
2654 if err != nil {
2655 return fmt.Errorf("error reading existing hook: %w", err)
2656 }
2657
2658 // Hook exists, check if our content is already in it by looking for a distinctive line
2659 code := string(buf)
2660 if strings.Contains(code, distinctiveLine) {
2661 // Already contains our content, nothing to do
2662 return nil
2663 }
2664
2665 // Append our content to the existing hook
2666 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2667 if err != nil {
2668 return fmt.Errorf("failed to open hook for appending: %w", err)
2669 }
2670 defer f.Close()
2671
2672 // Ensure there's a newline at the end of the existing content if needed
2673 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2674 _, err = f.WriteString("\n")
2675 if err != nil {
2676 return fmt.Errorf("failed to add newline to hook: %w", err)
2677 }
2678 }
2679
2680 // Add a separator before our content
2681 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2682 if err != nil {
2683 return fmt.Errorf("failed to append to hook: %w", err)
2684 }
2685
2686 return nil
2687}
Sean McCullough138ec242025-06-02 22:42:06 +00002688
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002689// configurePassthroughUpstream configures git remotes
2690// Adds an upstream remote pointing to the same as origin
2691// Sets the refspec for upstream and fetch such that both
2692// fetch the upstream's things into refs/remotes/upstream/foo
2693// The typical scenario is:
2694//
2695// github - laptop - sketch container
2696// "upstream" "origin"
2697func (a *Agent) configurePassthroughUpstream(ctx context.Context) error {
2698 // Get the origin remote URL
2699 cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
2700 cmd.Dir = a.workingDir
2701 originURLBytes, err := cmd.CombinedOutput()
2702 if err != nil {
2703 return fmt.Errorf("failed to get origin URL: %s: %w", originURLBytes, err)
2704 }
2705 originURL := strings.TrimSpace(string(originURLBytes))
2706
2707 // Check if upstream remote already exists
2708 cmd = exec.CommandContext(ctx, "git", "remote", "get-url", "upstream")
2709 cmd.Dir = a.workingDir
2710 if _, err := cmd.CombinedOutput(); err != nil {
2711 // upstream remote doesn't exist, create it
2712 cmd = exec.CommandContext(ctx, "git", "remote", "add", "upstream", originURL)
2713 cmd.Dir = a.workingDir
2714 if out, err := cmd.CombinedOutput(); err != nil {
2715 return fmt.Errorf("failed to add upstream remote: %s: %w", out, err)
2716 }
2717 slog.InfoContext(ctx, "added upstream remote", "url", originURL)
2718 } else {
2719 // upstream remote exists, update its URL
2720 cmd = exec.CommandContext(ctx, "git", "remote", "set-url", "upstream", originURL)
2721 cmd.Dir = a.workingDir
2722 if out, err := cmd.CombinedOutput(); err != nil {
2723 return fmt.Errorf("failed to set upstream remote URL: %s: %w", out, err)
2724 }
2725 slog.InfoContext(ctx, "updated upstream remote URL", "url", originURL)
2726 }
2727
2728 // Add the upstream refspec to the upstream remote
2729 cmd = exec.CommandContext(ctx, "git", "config", "remote.upstream.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2730 cmd.Dir = a.workingDir
2731 if out, err := cmd.CombinedOutput(); err != nil {
2732 return fmt.Errorf("failed to set upstream fetch refspec: %s: %w", out, err)
2733 }
2734
2735 // Add the same refspec to the origin remote
2736 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.origin.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2737 cmd.Dir = a.workingDir
2738 if out, err := cmd.CombinedOutput(); err != nil {
2739 return fmt.Errorf("failed to add upstream refspec to origin: %s: %w", out, err)
2740 }
2741
2742 slog.InfoContext(ctx, "configured passthrough upstream", "origin_url", originURL)
2743 return nil
2744}
2745
Philip Zeyliger0113be52025-06-07 23:53:41 +00002746// SkabandAddr returns the skaband address if configured
2747func (a *Agent) SkabandAddr() string {
2748 if a.config.SkabandClient != nil {
2749 return a.config.SkabandClient.Addr()
2750 }
2751 return ""
2752}
bankseanbdc68892025-07-28 17:28:13 -07002753
2754// ExternalMsg represents a message from a source external to the agent/user conversation,
2755// such as the outcome of a github workflow run.
2756type ExternalMessage struct {
2757 MessageType string `json:"message_type"`
2758 Body any `json:"body"`
2759 TextContent string `json:"text_content"`
2760}