blob: 310564dc689b20f3c0a57b6d5dcba68c0d1a968b [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 Snyder7f18fb62025-07-30 18:12:29 -070027 "sketch.dev/experiment"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070028 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070029 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070030 "sketch.dev/llm/conversation"
Philip Zeyliger194bfa82025-06-24 06:03:06 -070031 "sketch.dev/mcp"
Philip Zeyligerc17ffe32025-06-05 19:49:13 -070032 "sketch.dev/skabandclient"
Philip Zeyliger5f26a342025-07-04 01:30:29 +000033 "tailscale.com/portlist"
Earl Lee2e463fb2025-04-17 11:22:22 -070034)
35
36const (
37 userCancelMessage = "user requested agent to stop handling responses"
38)
39
Philip Zeyligerb7c58752025-05-01 10:10:17 -070040type MessageIterator interface {
41 // Next blocks until the next message is available. It may
42 // return nil if the underlying iterator context is done.
43 Next() *AgentMessage
44 Close()
45}
46
Earl Lee2e463fb2025-04-17 11:22:22 -070047type CodingAgent interface {
48 // Init initializes an agent inside a docker container.
49 Init(AgentInit) error
50
51 // Ready returns a channel closed after Init successfully called.
52 Ready() <-chan struct{}
53
54 // URL reports the HTTP URL of this agent.
55 URL() string
56
57 // UserMessage enqueues a message to the agent and returns immediately.
58 UserMessage(ctx context.Context, msg string)
59
Philip Zeyligerb7c58752025-05-01 10:10:17 -070060 // Returns an iterator that finishes when the context is done and
61 // starts with the given message index.
62 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070063
Philip Zeyligereab12de2025-05-14 02:35:53 +000064 // Returns an iterator that notifies of state transitions until the context is done.
65 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
66
Earl Lee2e463fb2025-04-17 11:22:22 -070067 // Loop begins the agent loop returns only when ctx is cancelled.
68 Loop(ctx context.Context)
69
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000070 // BranchPrefix returns the configured branch prefix
71 BranchPrefix() string
72
philip.zeyliger6d3de482025-06-10 19:38:14 -070073 // LinkToGitHub returns whether GitHub branch linking is enabled
74 LinkToGitHub() bool
75
Sean McCulloughedc88dc2025-04-30 02:55:01 +000076 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070077
78 CancelToolUse(toolUseID string, cause error) error
79
80 // Returns a subset of the agent's message history.
81 Messages(start int, end int) []AgentMessage
82
83 // Returns the current number of messages in the history
84 MessageCount() int
85
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070086 TotalUsage() conversation.CumulativeUsage
87 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070088
Earl Lee2e463fb2025-04-17 11:22:22 -070089 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000090 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070091
92 // Diff returns a unified diff of changes made since the agent was instantiated.
93 // If commit is non-nil, it shows the diff for just that specific commit.
94 Diff(commit *string) (string, error)
95
Philip Zeyliger49edc922025-05-14 09:45:45 -070096 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
97 // starts out as the commit where sketch started, but a user can move it if need
98 // be, for example in the case of a rebase. It is stored as a git tag.
99 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700100
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000101 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
102 // (Typically, this is "sketch-base")
103 SketchGitBaseRef() string
104
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700105 // Slug returns the slug identifier for this session.
106 Slug() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700107
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000108 // BranchName returns the git branch name for the conversation.
109 BranchName() string
110
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700111 // IncrementRetryNumber increments the retry number for branch naming conflicts.
112 IncrementRetryNumber()
113
Earl Lee2e463fb2025-04-17 11:22:22 -0700114 // OS returns the operating system of the client.
115 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000116
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000117 // SessionID returns the unique session identifier.
118 SessionID() string
119
philip.zeyliger8773e682025-06-11 21:36:21 -0700120 // SSHConnectionString returns the SSH connection string for the container.
121 SSHConnectionString() string
122
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000123 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700124 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000125
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000126 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
127 OutstandingLLMCallCount() int
128
129 // OutstandingToolCalls returns the names of outstanding tool calls.
130 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000131 OutsideOS() string
132 OutsideHostname() string
133 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000134 GitOrigin() string
Philip Zeyliger64f60462025-06-16 13:57:10 -0700135
bankseancad67b02025-06-27 21:57:05 +0000136 // GitUsername returns the git user name from the agent config.
137 GitUsername() string
138
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700139 // PassthroughUpstream returns whether passthrough upstream is enabled.
140 PassthroughUpstream() bool
141
Philip Zeyliger64f60462025-06-16 13:57:10 -0700142 // DiffStats returns the number of lines added and removed from sketch-base to HEAD
143 DiffStats() (int, int)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000144 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
145 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700146
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700147 // IsInContainer returns true if the agent is running in a container
148 IsInContainer() bool
149 // FirstMessageIndex returns the index of the first message in the current conversation
150 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700151
152 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700153 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
154 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700155
156 // CompactConversation compacts the current conversation by generating a summary
157 // and restarting the conversation with that summary as the initial context
158 CompactConversation(ctx context.Context) error
Philip Zeyligerda623b52025-07-04 01:12:38 +0000159
Philip Zeyliger0113be52025-06-07 23:53:41 +0000160 // SkabandAddr returns the skaband address if configured
161 SkabandAddr() string
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000162
163 // GetPorts returns the cached list of open TCP ports
164 GetPorts() []portlist.Port
banksean5ab8fb82025-07-09 12:34:55 -0700165
166 // TokenContextWindow returns the TokenContextWindow size of the model the agent is using.
167 TokenContextWindow() int
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000168
169 // ModelName returns the name of the model the agent is using.
170 ModelName() string
bankseanbdc68892025-07-28 17:28:13 -0700171
172 // ExternalMessage enqueues an external message to the agent and returns immediately.
173 ExternalMessage(ctx context.Context, msg ExternalMessage) error
Earl Lee2e463fb2025-04-17 11:22:22 -0700174}
175
176type CodingAgentMessageType string
177
178const (
bankseanbdc68892025-07-28 17:28:13 -0700179 UserMessageType CodingAgentMessageType = "user"
180 AgentMessageType CodingAgentMessageType = "agent"
181 ErrorMessageType CodingAgentMessageType = "error"
182 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
183 ToolUseMessageType CodingAgentMessageType = "tool"
184 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
185 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
186 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
187 PortMessageType CodingAgentMessageType = "port" // for port monitoring events
188 SlugMessageType CodingAgentMessageType = "slug" // for slug updates
189 ExternalMessageType CodingAgentMessageType = "external" // for external notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700190
191 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
192)
193
194type AgentMessage struct {
195 Type CodingAgentMessageType `json:"type"`
196 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
197 EndOfTurn bool `json:"end_of_turn"`
198
bankseanbdc68892025-07-28 17:28:13 -0700199 Content string `json:"content"`
200 ExternalMessage *ExternalMessage `json:"external_message,omitempty"`
201 ToolName string `json:"tool_name,omitempty"`
202 ToolInput string `json:"input,omitempty"`
203 ToolResult string `json:"tool_result,omitempty"`
204 ToolError bool `json:"tool_error,omitempty"`
205 ToolCallId string `json:"tool_call_id,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700206
207 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
208 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
209
Sean McCulloughd9f13372025-04-21 15:08:49 -0700210 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
211 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
212
Earl Lee2e463fb2025-04-17 11:22:22 -0700213 // Commits is a list of git commits for a commit message
214 Commits []*GitCommit `json:"commits,omitempty"`
215
216 Timestamp time.Time `json:"timestamp"`
217 ConversationID string `json:"conversation_id"`
218 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700219 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700220
221 // Message timing information
222 StartTime *time.Time `json:"start_time,omitempty"`
223 EndTime *time.Time `json:"end_time,omitempty"`
224 Elapsed *time.Duration `json:"elapsed,omitempty"`
225
226 // Turn duration - the time taken for a complete agent turn
227 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
228
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000229 // HideOutput indicates that this message should not be rendered in the UI.
230 // This is useful for subconversations that generate output that shouldn't be shown to the user.
231 HideOutput bool `json:"hide_output,omitempty"`
232
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700233 // TodoContent contains the agent's todo file content when it has changed
234 TodoContent *string `json:"todo_content,omitempty"`
235
Josh Bleecher Snyder3dd3e412025-07-22 20:32:03 -0700236 // Display contains content to be displayed to the user, set by tools
237 Display any `json:"display,omitempty"`
238
Earl Lee2e463fb2025-04-17 11:22:22 -0700239 Idx int `json:"idx"`
240}
241
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000242// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700243func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700244 if convo == nil {
245 m.ConversationID = ""
246 m.ParentConversationID = nil
247 return
248 }
249 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000250 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700251 if convo.Parent != nil {
252 m.ParentConversationID = &convo.Parent.ID
253 }
254}
255
Earl Lee2e463fb2025-04-17 11:22:22 -0700256// GitCommit represents a single git commit for a commit message
257type GitCommit struct {
258 Hash string `json:"hash"` // Full commit hash
259 Subject string `json:"subject"` // Commit subject line
260 Body string `json:"body"` // Full commit message body
261 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
262}
263
264// ToolCall represents a single tool call within an agent message
265type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700266 Name string `json:"name"`
267 Input string `json:"input"`
268 ToolCallId string `json:"tool_call_id"`
269 ResultMessage *AgentMessage `json:"result_message,omitempty"`
270 Args string `json:"args,omitempty"`
271 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700272}
273
274func (a *AgentMessage) Attr() slog.Attr {
275 var attrs []any = []any{
276 slog.String("type", string(a.Type)),
277 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700278 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700279 if a.EndOfTurn {
280 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
281 }
282 if a.Content != "" {
283 attrs = append(attrs, slog.String("content", a.Content))
284 }
285 if a.ToolName != "" {
286 attrs = append(attrs, slog.String("tool_name", a.ToolName))
287 }
288 if a.ToolInput != "" {
289 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
290 }
291 if a.Elapsed != nil {
292 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
293 }
294 if a.TurnDuration != nil {
295 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
296 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700297 if len(a.ToolResult) > 0 {
298 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700299 }
300 if a.ToolError {
301 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
302 }
303 if len(a.ToolCalls) > 0 {
304 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
305 for i, tc := range a.ToolCalls {
306 toolCallAttrs = append(toolCallAttrs, slog.Group(
307 fmt.Sprintf("tool_call_%d", i),
308 slog.String("name", tc.Name),
309 slog.String("input", tc.Input),
310 ))
311 }
312 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
313 }
314 if a.ConversationID != "" {
315 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
316 }
317 if a.ParentConversationID != nil {
318 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
319 }
320 if a.Usage != nil && !a.Usage.IsZero() {
321 attrs = append(attrs, a.Usage.Attr())
322 }
323 // TODO: timestamp, convo ids, idx?
324 return slog.Group("agent_message", attrs...)
325}
326
327func errorMessage(err error) AgentMessage {
328 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
329 if os.Getenv(("DEBUG")) == "1" {
330 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
331 }
332
333 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
334}
335
336func budgetMessage(err error) AgentMessage {
337 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
338}
339
340// ConvoInterface defines the interface for conversation interactions
341type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700342 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700343 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700344 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700345 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700346 SendMessage(message llm.Message) (*llm.Response, error)
347 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700348 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000349 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700350 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700351 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700352 SubConvoWithHistory() *conversation.Convo
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700353 DebugJSON() ([]byte, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700354}
355
Philip Zeyligerf2872992025-05-22 10:35:28 -0700356// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700357// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700358// any time we notice we need to.
359type AgentGitState struct {
360 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700361 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700362 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000363 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700364 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700365 slug string // Human-readable session identifier
366 retryNumber int // Number to append when branch conflicts occur
Philip Zeyliger64f60462025-06-16 13:57:10 -0700367 linesAdded int // Lines added from sketch-base to HEAD
368 linesRemoved int // Lines removed from sketch-base to HEAD
Philip Zeyligerf2872992025-05-22 10:35:28 -0700369}
370
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700371func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700372 ags.mu.Lock()
373 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700374 if ags.slug != slug {
375 ags.retryNumber = 0
376 }
377 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700378}
379
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700380func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700381 ags.mu.Lock()
382 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700383 return ags.slug
384}
385
386func (ags *AgentGitState) IncrementRetryNumber() {
387 ags.mu.Lock()
388 defer ags.mu.Unlock()
389 ags.retryNumber++
390}
391
Philip Zeyliger64f60462025-06-16 13:57:10 -0700392func (ags *AgentGitState) DiffStats() (int, int) {
393 ags.mu.Lock()
394 defer ags.mu.Unlock()
395 return ags.linesAdded, ags.linesRemoved
396}
397
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700398// HasSeenCommits returns true if any commits have been processed
399func (ags *AgentGitState) HasSeenCommits() bool {
400 ags.mu.Lock()
401 defer ags.mu.Unlock()
402 return len(ags.seenCommits) > 0
403}
404
405func (ags *AgentGitState) RetryNumber() int {
406 ags.mu.Lock()
407 defer ags.mu.Unlock()
408 return ags.retryNumber
409}
410
411func (ags *AgentGitState) BranchName(prefix string) string {
412 ags.mu.Lock()
413 defer ags.mu.Unlock()
414 return ags.branchNameLocked(prefix)
415}
416
417func (ags *AgentGitState) branchNameLocked(prefix string) string {
418 if ags.slug == "" {
419 return ""
420 }
421 if ags.retryNumber == 0 {
422 return prefix + ags.slug
423 }
424 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700425}
426
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000427func (ags *AgentGitState) Upstream() string {
428 ags.mu.Lock()
429 defer ags.mu.Unlock()
430 return ags.upstream
431}
432
Earl Lee2e463fb2025-04-17 11:22:22 -0700433type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700434 convo ConvoInterface
435 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700436 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700437 workingDir string
438 repoRoot string // workingDir may be a subdir of repoRoot
439 url string
440 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000441 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700442 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000443 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700444 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700445 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000446 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700447 // State machine to track agent state
448 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000449 // Outside information
450 outsideHostname string
451 outsideOS string
452 outsideWorkingDir string
Philip Zeyliger194bfa82025-06-24 06:03:06 -0700453 // MCP manager for handling MCP server connections
454 mcpManager *mcp.MCPManager
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000455 // Port monitor for tracking TCP ports
456 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700457
458 // Time when the current turn started (reset at the beginning of InnerLoop)
459 startOfTurn time.Time
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +0000460 now func() time.Time // override-able, defaults to time.Now
Earl Lee2e463fb2025-04-17 11:22:22 -0700461
462 // Inbox - for messages from the user to the agent.
463 // sent on by UserMessage
464 // . e.g. when user types into the chat textarea
465 // read from by GatherMessages
466 inbox chan string
467
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000468 // protects cancelTurn
469 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700470 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000471 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700472
473 // protects following
474 mu sync.Mutex
475
476 // Stores all messages for this agent
477 history []AgentMessage
478
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700479 // Iterators add themselves here when they're ready to be notified of new messages.
480 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700481
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000482 // Track outstanding LLM call IDs
483 outstandingLLMCalls map[string]struct{}
484
485 // Track outstanding tool calls by ID with their names
486 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700487}
488
bankseanbdc68892025-07-28 17:28:13 -0700489// ExternalMessage implements CodingAgent.
490// TODO: Debounce and/or coalesce these messages so they're less disruptive to the conversation.
491func (a *Agent) ExternalMessage(ctx context.Context, msg ExternalMessage) error {
492 agentMsg := AgentMessage{
493 Type: ExternalMessageType,
494 ExternalMessage: &msg,
495 }
496 a.pushToOutbox(ctx, agentMsg)
497 a.inbox <- msg.TextContent
498 return nil
499}
500
banksean5ab8fb82025-07-09 12:34:55 -0700501// TokenContextWindow implements CodingAgent.
502func (a *Agent) TokenContextWindow() int {
503 return a.config.Service.TokenContextWindow()
504}
505
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000506// ModelName returns the name of the model the agent is using.
507func (a *Agent) ModelName() string {
508 return a.config.Model
509}
510
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700511// GetConvo returns the conversation interface for debugging purposes.
512func (a *Agent) GetConvo() ConvoInterface {
513 return a.convo
514}
515
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700516// NewIterator implements CodingAgent.
517func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
518 a.mu.Lock()
519 defer a.mu.Unlock()
520
521 return &MessageIteratorImpl{
522 agent: a,
523 ctx: ctx,
524 nextMessageIdx: nextMessageIdx,
525 ch: make(chan *AgentMessage, 100),
526 }
527}
528
529type MessageIteratorImpl struct {
530 agent *Agent
531 ctx context.Context
532 nextMessageIdx int
533 ch chan *AgentMessage
534 subscribed bool
535}
536
537func (m *MessageIteratorImpl) Close() {
538 m.agent.mu.Lock()
539 defer m.agent.mu.Unlock()
540 // Delete ourselves from the subscribers list
541 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
542 return x == m.ch
543 })
544 close(m.ch)
545}
546
547func (m *MessageIteratorImpl) Next() *AgentMessage {
548 // We avoid subscription at creation to let ourselves catch up to "current state"
549 // before subscribing.
550 if !m.subscribed {
551 m.agent.mu.Lock()
552 if m.nextMessageIdx < len(m.agent.history) {
553 msg := &m.agent.history[m.nextMessageIdx]
554 m.nextMessageIdx++
555 m.agent.mu.Unlock()
556 return msg
557 }
558 // The next message doesn't exist yet, so let's subscribe
559 m.agent.subscribers = append(m.agent.subscribers, m.ch)
560 m.subscribed = true
561 m.agent.mu.Unlock()
562 }
563
564 for {
565 select {
566 case <-m.ctx.Done():
567 m.agent.mu.Lock()
568 // Delete ourselves from the subscribers list
569 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
570 return x == m.ch
571 })
572 m.subscribed = false
573 m.agent.mu.Unlock()
574 return nil
575 case msg, ok := <-m.ch:
576 if !ok {
577 // Close may have been called
578 return nil
579 }
580 if msg.Idx == m.nextMessageIdx {
581 m.nextMessageIdx++
582 return msg
583 }
584 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
585 panic("out of order message")
586 }
587 }
588}
589
Sean McCulloughd9d45812025-04-30 16:53:41 -0700590// Assert that Agent satisfies the CodingAgent interface.
591var _ CodingAgent = &Agent{}
592
593// StateName implements CodingAgent.
594func (a *Agent) CurrentStateName() string {
595 if a.stateMachine == nil {
596 return ""
597 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000598 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700599}
600
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700601// CurrentTodoContent returns the current todo list data as JSON.
602// It returns an empty string if no todos exist.
603func (a *Agent) CurrentTodoContent() string {
604 todoPath := claudetool.TodoFilePath(a.config.SessionID)
605 content, err := os.ReadFile(todoPath)
606 if err != nil {
607 return ""
608 }
609 return string(content)
610}
611
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700612// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
613func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
614 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.
615
616IMPORTANT: 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.
617
618Please create a detailed summary that includes:
619
6201. **User's Request**: What did the user originally ask me to do? What was their goal?
621
6222. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
623
6243. **Key Technical Decisions**: What important technical choices were made during our work and why?
625
6264. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
627
6285. **Next Steps**: What still needs to be done to complete the user's request?
629
6306. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
631
632Focus 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.
633
634Reply with ONLY the summary content - no meta-commentary about creating the summary.`
635
636 userMessage := llm.UserStringMessage(msg)
637 // Use a subconversation with history to get the summary
638 // TODO: We don't have any tools here, so we should have enough tokens
639 // to capture a summary, but we may need to modify the history (e.g., remove
640 // TODO data) to save on some tokens.
641 convo := a.convo.SubConvoWithHistory()
642
643 // Modify the system prompt to provide context about the original task
644 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000645 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 -0700646
647Your 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.
648
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000649Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700650
651 resp, err := convo.SendMessage(userMessage)
652 if err != nil {
653 a.pushToOutbox(ctx, errorMessage(err))
654 return "", err
655 }
656 textContent := collectTextContent(resp)
657
658 // Restore original system prompt (though this subconvo will be discarded)
659 convo.SystemPrompt = originalSystemPrompt
660
661 return textContent, nil
662}
663
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000664// dumpMessageHistoryToTmp dumps the agent's entire message history to /tmp as JSON
665// and returns the filename
666func (a *Agent) dumpMessageHistoryToTmp(ctx context.Context) (string, error) {
667 // Create a filename based on session ID and timestamp
668 timestamp := time.Now().Format("20060102-150405")
669 filename := fmt.Sprintf("/tmp/sketch-messages-%s-%s.json", a.config.SessionID, timestamp)
670
671 // Marshal the entire message history to JSON
672 jsonData, err := json.MarshalIndent(a.history, "", " ")
673 if err != nil {
674 return "", fmt.Errorf("failed to marshal message history: %w", err)
675 }
676
677 // Write to file
Autoformatter3ad8c8d2025-07-15 21:05:23 +0000678 if err := os.WriteFile(filename, jsonData, 0o644); err != nil {
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000679 return "", fmt.Errorf("failed to write message history to %s: %w", filename, err)
680 }
681
682 slog.InfoContext(ctx, "Dumped message history to file", "filename", filename, "message_count", len(a.history))
683 return filename, nil
684}
685
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700686// CompactConversation compacts the current conversation by generating a summary
687// and restarting the conversation with that summary as the initial context
688func (a *Agent) CompactConversation(ctx context.Context) error {
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000689 // Dump the entire message history to /tmp as JSON before compacting
690 dumpFile, err := a.dumpMessageHistoryToTmp(ctx)
691 if err != nil {
692 slog.WarnContext(ctx, "Failed to dump message history to /tmp", "error", err)
693 // Continue with compaction even if dump fails
694 }
695
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700696 summary, err := a.generateConversationSummary(ctx)
697 if err != nil {
698 return fmt.Errorf("failed to generate conversation summary: %w", err)
699 }
700
701 a.mu.Lock()
702
703 // Get usage information before resetting conversation
704 lastUsage := a.convo.LastUsage()
705 contextWindow := a.config.Service.TokenContextWindow()
706 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
707
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000708 // Preserve cumulative usage across compaction
709 cumulativeUsage := a.convo.CumulativeUsage()
710
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700711 // Reset conversation state but keep all other state (git, working dir, etc.)
712 a.firstMessageIndex = len(a.history)
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000713 a.convo = a.initConvoWithUsage(&cumulativeUsage)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700714
715 a.mu.Unlock()
716
717 // Create informative compaction message with token details
718 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
719 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
720 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
721
722 a.pushToOutbox(ctx, AgentMessage{
723 Type: CompactMessageType,
724 Content: compactionMsg,
725 })
726
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000727 // Create the message content with dump file information if available
728 var messageContent string
729 if dumpFile != "" {
730 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)
731 } else {
732 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)
733 }
734
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700735 a.pushToOutbox(ctx, AgentMessage{
736 Type: UserMessageType,
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000737 Content: messageContent,
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700738 })
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000739 a.inbox <- messageContent
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700740
741 return nil
742}
743
Earl Lee2e463fb2025-04-17 11:22:22 -0700744func (a *Agent) URL() string { return a.url }
745
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000746// GetPorts returns the cached list of open TCP ports.
747func (a *Agent) GetPorts() []portlist.Port {
748 if a.portMonitor == nil {
749 return nil
750 }
751 return a.portMonitor.GetPorts()
752}
753
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000754// BranchName returns the git branch name for the conversation.
755func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700756 return a.gitState.BranchName(a.config.BranchPrefix)
757}
758
759// Slug returns the slug identifier for this conversation.
760func (a *Agent) Slug() string {
761 return a.gitState.Slug()
762}
763
764// IncrementRetryNumber increments the retry number for branch naming conflicts
765func (a *Agent) IncrementRetryNumber() {
766 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000767}
768
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000769// OutstandingLLMCallCount returns the number of outstanding LLM calls.
770func (a *Agent) OutstandingLLMCallCount() int {
771 a.mu.Lock()
772 defer a.mu.Unlock()
773 return len(a.outstandingLLMCalls)
774}
775
776// OutstandingToolCalls returns the names of outstanding tool calls.
777func (a *Agent) OutstandingToolCalls() []string {
778 a.mu.Lock()
779 defer a.mu.Unlock()
780
781 tools := make([]string, 0, len(a.outstandingToolCalls))
782 for _, toolName := range a.outstandingToolCalls {
783 tools = append(tools, toolName)
784 }
785 return tools
786}
787
Earl Lee2e463fb2025-04-17 11:22:22 -0700788// OS returns the operating system of the client.
789func (a *Agent) OS() string {
790 return a.config.ClientGOOS
791}
792
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000793func (a *Agent) SessionID() string {
794 return a.config.SessionID
795}
796
philip.zeyliger8773e682025-06-11 21:36:21 -0700797// SSHConnectionString returns the SSH connection string for the container.
798func (a *Agent) SSHConnectionString() string {
799 return a.config.SSHConnectionString
800}
801
Philip Zeyliger18532b22025-04-23 21:11:46 +0000802// OutsideOS returns the operating system of the outside system.
803func (a *Agent) OutsideOS() string {
804 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000805}
806
Philip Zeyliger18532b22025-04-23 21:11:46 +0000807// OutsideHostname returns the hostname of the outside system.
808func (a *Agent) OutsideHostname() string {
809 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000810}
811
Philip Zeyliger18532b22025-04-23 21:11:46 +0000812// OutsideWorkingDir returns the working directory on the outside system.
813func (a *Agent) OutsideWorkingDir() string {
814 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000815}
816
817// GitOrigin returns the URL of the git remote 'origin' if it exists.
818func (a *Agent) GitOrigin() string {
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +0000819 return a.config.OriginalGitOrigin
Philip Zeyligerd1402952025-04-23 03:54:37 +0000820}
821
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700822// PassthroughUpstream returns whether passthrough upstream is enabled.
823func (a *Agent) PassthroughUpstream() bool {
824 return a.config.PassthroughUpstream
825}
826
bankseancad67b02025-06-27 21:57:05 +0000827// GitUsername returns the git user name from the agent config.
828func (a *Agent) GitUsername() string {
829 return a.config.GitUsername
830}
831
Philip Zeyliger64f60462025-06-16 13:57:10 -0700832// DiffStats returns the number of lines added and removed from sketch-base to HEAD
833func (a *Agent) DiffStats() (int, int) {
834 return a.gitState.DiffStats()
835}
836
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000837func (a *Agent) OpenBrowser(url string) {
838 if !a.IsInContainer() {
839 browser.Open(url)
840 return
841 }
842 // We're in Docker, need to send a request to the Git server
843 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700844 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000845 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700846 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000847 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700848 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000849 return
850 }
851 defer resp.Body.Close()
852 if resp.StatusCode == http.StatusOK {
853 return
854 }
855 body, _ := io.ReadAll(resp.Body)
856 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
857}
858
Sean McCullough96b60dd2025-04-30 09:49:10 -0700859// CurrentState returns the current state of the agent's state machine.
860func (a *Agent) CurrentState() State {
861 return a.stateMachine.CurrentState()
862}
863
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700864func (a *Agent) IsInContainer() bool {
865 return a.config.InDocker
866}
867
868func (a *Agent) FirstMessageIndex() int {
869 a.mu.Lock()
870 defer a.mu.Unlock()
871 return a.firstMessageIndex
872}
873
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700874// SetSlug sets a human-readable identifier for the conversation.
875func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700876 a.mu.Lock()
877 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700878
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700879 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000880 convo, ok := a.convo.(*conversation.Convo)
881 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700882 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000883 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700884}
885
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000886// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700887func (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 +0000888 // Track the tool call
889 a.mu.Lock()
890 a.outstandingToolCalls[id] = toolName
891 a.mu.Unlock()
892}
893
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700894// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
895// If there's only one element in the array and it's a text type, it returns that text directly.
896// It also processes nested ToolResult arrays recursively.
897func contentToString(contents []llm.Content) string {
898 if len(contents) == 0 {
899 return ""
900 }
901
902 // If there's only one element and it's a text type, return it directly
903 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
904 return contents[0].Text
905 }
906
907 // Otherwise, concatenate all text content
908 var result strings.Builder
909 for _, content := range contents {
910 if content.Type == llm.ContentTypeText {
911 result.WriteString(content.Text)
912 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
913 // Recursively process nested tool results
914 result.WriteString(contentToString(content.ToolResult))
915 }
916 }
917
918 return result.String()
919}
920
Earl Lee2e463fb2025-04-17 11:22:22 -0700921// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700922func (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 +0000923 // Remove the tool call from outstanding calls
924 a.mu.Lock()
925 delete(a.outstandingToolCalls, toolID)
926 a.mu.Unlock()
927
Earl Lee2e463fb2025-04-17 11:22:22 -0700928 m := AgentMessage{
929 Type: ToolUseMessageType,
930 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700931 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700932 ToolError: content.ToolError,
933 ToolName: toolName,
934 ToolInput: string(toolInput),
935 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700936 StartTime: content.ToolUseStartTime,
937 EndTime: content.ToolUseEndTime,
Josh Bleecher Snyder3dd3e412025-07-22 20:32:03 -0700938 Display: content.Display,
Earl Lee2e463fb2025-04-17 11:22:22 -0700939 }
940
941 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700942 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
943 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700944 m.Elapsed = &elapsed
945 }
946
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700947 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700948 a.pushToOutbox(ctx, m)
949}
950
951// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700952func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000953 a.mu.Lock()
954 defer a.mu.Unlock()
955 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700956 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
957}
958
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700959// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700960// that need to be displayed (as well as tool calls that we send along when
961// they're done). (It would be reasonable to also mention tool calls when they're
962// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700963func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000964 // Remove the LLM call from outstanding calls
965 a.mu.Lock()
966 delete(a.outstandingLLMCalls, id)
967 a.mu.Unlock()
968
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700969 if resp == nil {
970 // LLM API call failed
971 m := AgentMessage{
972 Type: ErrorMessageType,
973 Content: "API call failed, type 'continue' to try again",
974 }
975 m.SetConvo(convo)
976 a.pushToOutbox(ctx, m)
977 return
978 }
979
Earl Lee2e463fb2025-04-17 11:22:22 -0700980 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700981 if convo.Parent == nil { // subconvos never end the turn
982 switch resp.StopReason {
983 case llm.StopReasonToolUse:
984 // Check whether any of the tool calls are for tools that should end the turn
985 ToolSearch:
986 for _, part := range resp.Content {
987 if part.Type != llm.ContentTypeToolUse {
988 continue
989 }
Sean McCullough021557a2025-05-05 23:20:53 +0000990 // Find the tool by name
991 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700992 if tool.Name == part.ToolName {
993 endOfTurn = tool.EndsTurn
994 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000995 }
996 }
Sean McCullough021557a2025-05-05 23:20:53 +0000997 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700998 default:
999 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +00001000 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001001 }
1002 m := AgentMessage{
1003 Type: AgentMessageType,
1004 Content: collectTextContent(resp),
1005 EndOfTurn: endOfTurn,
1006 Usage: &resp.Usage,
1007 StartTime: resp.StartTime,
1008 EndTime: resp.EndTime,
1009 }
1010
1011 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001012 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -07001013 var toolCalls []ToolCall
1014 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001015 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -07001016 toolCalls = append(toolCalls, ToolCall{
1017 Name: part.ToolName,
1018 Input: string(part.ToolInput),
1019 ToolCallId: part.ID,
1020 })
1021 }
1022 }
1023 m.ToolCalls = toolCalls
1024 }
1025
1026 // Calculate the elapsed time if both start and end times are set
1027 if resp.StartTime != nil && resp.EndTime != nil {
1028 elapsed := resp.EndTime.Sub(*resp.StartTime)
1029 m.Elapsed = &elapsed
1030 }
1031
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -07001032 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -07001033 a.pushToOutbox(ctx, m)
1034}
1035
1036// WorkingDir implements CodingAgent.
1037func (a *Agent) WorkingDir() string {
1038 return a.workingDir
1039}
1040
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001041// RepoRoot returns the git repository root directory.
1042func (a *Agent) RepoRoot() string {
1043 return a.repoRoot
1044}
1045
Earl Lee2e463fb2025-04-17 11:22:22 -07001046// MessageCount implements CodingAgent.
1047func (a *Agent) MessageCount() int {
1048 a.mu.Lock()
1049 defer a.mu.Unlock()
1050 return len(a.history)
1051}
1052
1053// Messages implements CodingAgent.
1054func (a *Agent) Messages(start int, end int) []AgentMessage {
1055 a.mu.Lock()
1056 defer a.mu.Unlock()
1057 return slices.Clone(a.history[start:end])
1058}
1059
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001060// ShouldCompact checks if the conversation should be compacted based on token usage
1061func (a *Agent) ShouldCompact() bool {
1062 // Get the threshold from environment variable, default to 0.94 (94%)
1063 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
1064 // and a little bit of buffer.)
1065 thresholdRatio := 0.94
1066 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
1067 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
1068 thresholdRatio = parsed
1069 }
1070 }
1071
1072 // Get the most recent usage to check current context size
1073 lastUsage := a.convo.LastUsage()
1074
1075 if lastUsage.InputTokens == 0 {
1076 // No API calls made yet
1077 return false
1078 }
1079
1080 // Calculate the current context size from the last API call
1081 // This includes all tokens that were part of the input context:
1082 // - Input tokens (user messages, system prompt, conversation history)
1083 // - Cache read tokens (cached parts of the context)
1084 // - Cache creation tokens (new parts being cached)
1085 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
1086
1087 // Get the service's token context window
1088 service := a.config.Service
1089 contextWindow := service.TokenContextWindow()
1090
1091 // Calculate threshold
1092 threshold := uint64(float64(contextWindow) * thresholdRatio)
1093
1094 // Check if we've exceeded the threshold
1095 return currentContextSize >= threshold
1096}
1097
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001098func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -07001099 return a.originalBudget
1100}
1101
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001102// Upstream returns the upstream branch for git work
1103func (a *Agent) Upstream() string {
1104 return a.gitState.Upstream()
1105}
1106
Earl Lee2e463fb2025-04-17 11:22:22 -07001107// AgentConfig contains configuration for creating a new Agent.
1108type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001109 Context context.Context
1110 Service llm.Service
1111 Budget conversation.Budget
1112 GitUsername string
1113 GitEmail string
1114 SessionID string
1115 ClientGOOS string
1116 ClientGOARCH string
1117 InDocker bool
1118 OneShot bool
1119 WorkingDir string
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +00001120 // Model is the name of the LLM model being used
1121 Model string
Philip Zeyliger18532b22025-04-23 21:11:46 +00001122 // Outside information
1123 OutsideHostname string
1124 OutsideOS string
1125 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001126
1127 // Outtie's HTTP to, e.g., open a browser
1128 OutsideHTTP string
1129 // Outtie's Git server
1130 GitRemoteAddr string
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001131 // Original git origin URL from host repository, if any
1132 OriginalGitOrigin string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001133 // Upstream branch for git work
1134 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001135 // Commit to checkout from Outtie
1136 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001137 // Prefix for git branches created by sketch
1138 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001139 // LinkToGitHub enables GitHub branch linking in UI
1140 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001141 // SSH connection string for connecting to the container
1142 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001143 // Skaband client for session history (optional)
1144 SkabandClient *skabandclient.SkabandClient
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001145 // MCP server configurations
1146 MCPServers []string
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001147 // Timeout configuration for bash tool
1148 BashTimeouts *claudetool.Timeouts
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001149 // PassthroughUpstream configures upstream remote for passthrough to innie
1150 PassthroughUpstream bool
Josh Bleecher Snyder1e551672025-07-30 03:16:54 +00001151 // FetchOnLaunch enables git fetch during initialization
1152 FetchOnLaunch bool
Earl Lee2e463fb2025-04-17 11:22:22 -07001153}
1154
1155// NewAgent creates a new Agent.
1156// It is not usable until Init() is called.
1157func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001158 // Set default branch prefix if not specified
1159 if config.BranchPrefix == "" {
1160 config.BranchPrefix = "sketch/"
1161 }
1162
Earl Lee2e463fb2025-04-17 11:22:22 -07001163 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001164 config: config,
1165 ready: make(chan struct{}),
1166 inbox: make(chan string, 100),
1167 subscribers: make([]chan *AgentMessage, 0),
1168 startedAt: time.Now(),
1169 originalBudget: config.Budget,
1170 gitState: AgentGitState{
1171 seenCommits: make(map[string]bool),
1172 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001173 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001174 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001175 outsideHostname: config.OutsideHostname,
1176 outsideOS: config.OutsideOS,
1177 outsideWorkingDir: config.OutsideWorkingDir,
1178 outstandingLLMCalls: make(map[string]struct{}),
1179 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001180 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001181 workingDir: config.WorkingDir,
1182 outsideHTTP: config.OutsideHTTP,
Philip Zeyligerda623b52025-07-04 01:12:38 +00001183
1184 mcpManager: mcp.NewMCPManager(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001185 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001186
1187 // Initialize port monitor with 5-second interval
1188 agent.portMonitor = NewPortMonitor(agent, 5*time.Second)
1189
Earl Lee2e463fb2025-04-17 11:22:22 -07001190 return agent
1191}
1192
1193type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001194 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001195
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001196 InDocker bool
1197 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001198}
1199
1200func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001201 if a.convo != nil {
1202 return fmt.Errorf("Agent.Init: already initialized")
1203 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001204 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001205 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001206
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001207 // If a remote + commit was specified, clone it.
1208 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001209 if _, err := os.Stat("/app/.git"); err != nil {
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00001210 slog.InfoContext(ctx, "cloning git repo", "commit", a.config.Commit)
1211 // TODO: --reference-if-able instead?
1212 cmd := exec.CommandContext(ctx, "git", "clone", "--reference", "/git-ref", a.gitState.gitRemoteAddr, "/app")
1213 if out, err := cmd.CombinedOutput(); err != nil {
1214 return fmt.Errorf("failed to clone repository from %s: %s: %w", a.gitState.gitRemoteAddr, out, err)
1215 }
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001216 }
1217 }
1218
1219 if a.workingDir != "" {
1220 err := os.Chdir(a.workingDir)
1221 if err != nil {
1222 return fmt.Errorf("failed to change working directory to %s: %w", a.workingDir, err)
1223 }
1224 }
1225
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001226 if !ini.NoGit {
Philip Zeyligeraccf37c2025-07-18 07:29:19 -07001227 if a.gitState.gitRemoteAddr != "" {
1228 if err := upsertRemoteOrigin(ctx, "/app", a.gitState.gitRemoteAddr); err != nil {
1229 return err
1230 }
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001231 }
Philip Zeyligere1c8b7b2025-07-03 14:50:26 -07001232
1233 // Configure git user settings
1234 if a.config.GitEmail != "" {
1235 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.email", a.config.GitEmail)
1236 cmd.Dir = a.workingDir
1237 if out, err := cmd.CombinedOutput(); err != nil {
1238 return fmt.Errorf("git config --global user.email: %s: %v", out, err)
1239 }
1240 }
1241 if a.config.GitUsername != "" {
1242 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.name", a.config.GitUsername)
1243 cmd.Dir = a.workingDir
1244 if out, err := cmd.CombinedOutput(); err != nil {
1245 return fmt.Errorf("git config --global user.name: %s: %v", out, err)
1246 }
1247 }
1248 // Configure git http.postBuffer
1249 cmd := exec.CommandContext(ctx, "git", "config", "--global", "http.postBuffer", "524288000")
1250 cmd.Dir = a.workingDir
1251 if out, err := cmd.CombinedOutput(); err != nil {
1252 return fmt.Errorf("git config --global http.postBuffer: %s: %v", out, err)
1253 }
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001254
1255 // Configure passthrough upstream if enabled
1256 if a.config.PassthroughUpstream {
1257 if err := a.configurePassthroughUpstream(ctx); err != nil {
1258 return fmt.Errorf("failed to configure passthrough upstream: %w", err)
1259 }
1260 }
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001261 }
1262
Philip Zeyligerf2872992025-05-22 10:35:28 -07001263 // If a commit was specified, we fetch and reset to it.
1264 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Josh Bleecher Snyder1e551672025-07-30 03:16:54 +00001265 if a.config.FetchOnLaunch {
1266 slog.InfoContext(ctx, "updating git repo", "commit", a.config.Commit)
1267 cmd := exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
1268 cmd.Dir = a.workingDir
1269 if out, err := cmd.CombinedOutput(); err != nil {
1270 return fmt.Errorf("git fetch: %s: %w", out, err)
1271 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001272 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001273 // The -B resets the branch if it already exists (or creates it if it doesn't)
Josh Bleecher Snyder1e551672025-07-30 03:16:54 +00001274 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001275 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001276 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1277 // Remove git hooks if they exist and retry
1278 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001279 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001280 if _, statErr := os.Stat(hookPath); statErr == nil {
1281 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1282 slog.String("error", err.Error()),
1283 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001284 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001285 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1286 }
1287
1288 // Retry the checkout operation
Philip Zeyliger1417b692025-06-12 11:07:04 -07001289 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001290 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001291 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001292 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 +01001293 }
1294 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001295 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001296 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001297 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001298 } else if a.IsInContainer() {
1299 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1300 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1301 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1302 cmd.Dir = a.workingDir
1303 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1304 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1305 }
1306 } else {
1307 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001308 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001309
1310 if ini.HostAddr != "" {
1311 a.url = "http://" + ini.HostAddr
1312 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001313
1314 if !ini.NoGit {
1315 repoRoot, err := repoRoot(ctx, a.workingDir)
1316 if err != nil {
1317 return fmt.Errorf("repoRoot: %w", err)
1318 }
1319 a.repoRoot = repoRoot
1320
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001321 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001322 if err := setupGitHooks(a.repoRoot); err != nil {
1323 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1324 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001325 }
1326
philz24613202025-07-15 20:56:21 -07001327 // Check if we have any commits, and if not, create an empty initial commit
1328 cmd := exec.CommandContext(ctx, "git", "rev-list", "--all", "--count")
1329 cmd.Dir = repoRoot
1330 countOut, err := cmd.CombinedOutput()
1331 if err != nil {
1332 return fmt.Errorf("git rev-list --all --count: %s: %w", countOut, err)
1333 }
1334 commitCount := strings.TrimSpace(string(countOut))
1335 if commitCount == "0" {
1336 slog.Info("No commits found, creating empty initial commit")
1337 cmd = exec.CommandContext(ctx, "git", "commit", "--allow-empty", "-m", "Initial empty commit")
1338 cmd.Dir = repoRoot
1339 if commitOut, err := cmd.CombinedOutput(); err != nil {
1340 return fmt.Errorf("git commit --allow-empty: %s: %w", commitOut, err)
1341 }
1342 }
1343
1344 cmd = exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
Philip Zeyliger49edc922025-05-14 09:45:45 -07001345 cmd.Dir = repoRoot
1346 if out, err := cmd.CombinedOutput(); err != nil {
1347 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1348 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001349
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001350 slog.Info("running codebase analysis")
1351 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1352 if err != nil {
1353 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001354 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001355 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001356
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001357 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001358 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001359 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001360 }
1361 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001362
Earl Lee2e463fb2025-04-17 11:22:22 -07001363 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001364 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001365 a.convo = a.initConvo()
1366 close(a.ready)
1367 return nil
1368}
1369
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001370//go:embed agent_system_prompt.txt
1371var agentSystemPrompt string
1372
Earl Lee2e463fb2025-04-17 11:22:22 -07001373// initConvo initializes the conversation.
1374// It must not be called until all agent fields are initialized,
1375// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001376func (a *Agent) initConvo() *conversation.Convo {
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001377 return a.initConvoWithUsage(nil)
1378}
1379
1380// initConvoWithUsage initializes the conversation with optional preserved usage.
1381func (a *Agent) initConvoWithUsage(usage *conversation.CumulativeUsage) *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001382 ctx := a.config.Context
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001383 convo := conversation.New(ctx, a.config.Service, usage)
Earl Lee2e463fb2025-04-17 11:22:22 -07001384 convo.PromptCaching = true
1385 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001386 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001387 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001388
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001389 bashTool := &claudetool.BashTool{
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001390 EnableJITInstall: claudetool.EnableBashToolJITInstall,
1391 Timeouts: a.config.BashTimeouts,
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -07001392 Pwd: a.workingDir,
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001393 }
Josh Bleecher Snyder04f16a52025-07-30 11:46:25 -07001394 patchTool := &claudetool.PatchTool{
Josh Bleecher Snyder7f18fb62025-07-30 18:12:29 -07001395 Callback: a.patchCallback,
1396 Pwd: a.workingDir,
1397 ClipboardEnabled: experiment.Enabled("clipboard"),
Josh Bleecher Snyder04f16a52025-07-30 11:46:25 -07001398 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001399
Earl Lee2e463fb2025-04-17 11:22:22 -07001400 // Register all tools with the conversation
1401 // When adding, removing, or modifying tools here, double-check that the termui tool display
1402 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001403
1404 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001405 _, supportsScreenshots := a.config.Service.(*ant.Service)
1406 var bTools []*llm.Tool
1407 var browserCleanup func()
1408
1409 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1410 // Add cleanup function to context cancel
1411 go func() {
1412 <-a.config.Context.Done()
1413 browserCleanup()
1414 }()
1415 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001416
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001417 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd64bc912025-07-24 11:42:33 -07001418 bashTool.Tool(),
1419 claudetool.Keyword,
Josh Bleecher Snyder04f16a52025-07-30 11:46:25 -07001420 patchTool.Tool(),
Josh Bleecher Snyderd64bc912025-07-24 11:42:33 -07001421 claudetool.Think,
1422 claudetool.TodoRead,
1423 claudetool.TodoWrite,
1424 makeDoneTool(a.codereview),
1425 a.codereview.Tool(),
1426 claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001427 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001428 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001429
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001430 // Add MCP tools if configured
1431 if len(a.config.MCPServers) > 0 {
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001432
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001433 slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers))
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001434 serverConfigs, parseErrors := mcp.ParseServerConfigs(ctx, a.config.MCPServers)
1435
1436 // Replace any headers with value _sketch_public_key_ and _sketch_session_id_ with those values.
1437 for i := range serverConfigs {
1438 if serverConfigs[i].Headers != nil {
1439 for key, value := range serverConfigs[i].Headers {
Philip Zeyligerf2814ea2025-06-30 10:16:50 -07001440 // Replace env placeholders. E.g., "env:FOO" becomes os.Getenv("FOO")
1441 if strings.HasPrefix(value, "env:") {
1442 serverConfigs[i].Headers[key] = os.Getenv(value[4:])
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001443 }
1444 }
1445 }
1446 }
Philip Zeyligerc540df72025-07-25 09:21:56 -07001447 mcpConnections, mcpErrors := a.mcpManager.ConnectToServerConfigs(ctx, serverConfigs, mcp.DefaultMCPConnectionTimeout, parseErrors)
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001448
1449 if len(mcpErrors) > 0 {
1450 for _, err := range mcpErrors {
1451 slog.ErrorContext(ctx, "MCP connection error", "error", err)
1452 // Send agent message about MCP connection failures
1453 a.pushToOutbox(ctx, AgentMessage{
1454 Type: ErrorMessageType,
1455 Content: fmt.Sprintf("MCP server connection failed: %v", err),
1456 })
1457 }
1458 }
1459
1460 if len(mcpConnections) > 0 {
1461 // Add tools from all successful connections
1462 totalTools := 0
1463 for _, connection := range mcpConnections {
1464 convo.Tools = append(convo.Tools, connection.Tools...)
1465 totalTools += len(connection.Tools)
1466 // Log tools per server using structured data
1467 slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames)
1468 }
1469 slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools)
1470 } else {
1471 slog.InfoContext(ctx, "No MCP tools available after connection attempts")
1472 }
1473 }
1474
Earl Lee2e463fb2025-04-17 11:22:22 -07001475 convo.Listener = a
1476 return convo
1477}
1478
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001479// branchExists reports whether branchName exists, either locally or in well-known remotes.
1480func branchExists(dir, branchName string) bool {
1481 refs := []string{
1482 "refs/heads/",
1483 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001484 }
1485 for _, ref := range refs {
1486 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1487 cmd.Dir = dir
1488 if cmd.Run() == nil { // exit code 0 means branch exists
1489 return true
1490 }
1491 }
1492 return false
1493}
1494
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001495func soleText(contents []llm.Content) (string, error) {
1496 if len(contents) != 1 {
1497 return "", fmt.Errorf("multiple contents %v", contents)
1498 }
1499 content := contents[0]
1500 if content.Type != llm.ContentTypeText || content.Text == "" {
1501 return "", fmt.Errorf("bad content %v", content)
1502 }
1503 return strings.TrimSpace(content.Text), nil
1504}
1505
1506// autoGenerateSlug automatically generates a slug based on the first user input
1507func (a *Agent) autoGenerateSlug(ctx context.Context, userContents []llm.Content) error {
1508 userText, err := soleText(userContents)
1509 if err != nil {
1510 return err
1511 }
1512 if userText == "" {
1513 return fmt.Errorf("set-slug: empty text content")
1514 }
1515
1516 // Create a subconversation without history for slug generation
1517 convo, ok := a.convo.(*conversation.Convo)
1518 if !ok {
1519 // In test environments, the conversation might be a mock interface
1520 // Skip slug generation in this case
1521 return fmt.Errorf("set-slug: can't make a subconvo (mock convo?)")
1522 }
1523
1524 // Loop until we find an acceptable slug
1525 var unavailableSlugs []string
1526 for {
1527 if len(unavailableSlugs) > 10 {
1528 // sanity check to prevent infinite loops
1529 return fmt.Errorf("set-slug: failed to construct a new slug after %d attempts", len(unavailableSlugs))
Earl Lee2e463fb2025-04-17 11:22:22 -07001530 }
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001531 subConvo := convo.SubConvo()
1532 subConvo.Hidden = true
1533
1534 // Prompt for slug generation
1535 prompt := `You are a slug generator for Sketch, an agentic coding environment.
1536The user's prompt will be in <user-prompt> tags. Any unavailable slugs will be listed in <unavailable-slug> tags.
1537Generate a 2-3 word alphanumeric hyphenated slug in imperative tense that captures the essence of their coding task.
1538Respond with only the slug.`
1539
1540 buf := new(strings.Builder)
1541 buf.WriteString("<slug-request>")
1542 if len(unavailableSlugs) > 0 {
1543 buf.WriteString("<unavailable-slugs>")
1544 }
1545 for _, x := range unavailableSlugs {
1546 buf.WriteString("<unavailable-slug>")
1547 buf.WriteString(x)
1548 buf.WriteString("</unavailable-slug>")
1549 }
1550 if len(unavailableSlugs) > 0 {
1551 buf.WriteString("</unavailable-slugs>")
1552 }
1553 buf.WriteString("<user-prompt>")
1554 buf.WriteString(userText)
1555 buf.WriteString("</user-prompt>")
1556 buf.WriteString("</slug-request>")
1557
1558 fullPrompt := prompt + "\n" + buf.String()
1559 userMessage := llm.UserStringMessage(fullPrompt)
1560
1561 resp, err := subConvo.SendMessage(userMessage)
1562 if err != nil {
1563 return fmt.Errorf("failed to generate slug: %w", err)
1564 }
1565
1566 // Extract the slug from the response
1567 slugText, err := soleText(resp.Content)
1568 if err != nil {
1569 return err
1570 }
1571 if slugText == "" {
1572 return fmt.Errorf("empty slug generated")
1573 }
1574
1575 // Clean and validate the slug
1576 slug := cleanSlugName(slugText)
1577 if slug == "" {
1578 return fmt.Errorf("slug could not be cleaned: %q", slugText)
1579 }
1580
1581 // Check if branch already exists using the same logic as the original set-slug tool
1582 a.SetSlug(slug) // Set slug first so BranchName() works correctly
1583 if branchExists(a.workingDir, a.BranchName()) {
1584 // try again
1585 unavailableSlugs = append(unavailableSlugs, slug)
1586 continue
1587 }
1588
1589 // Success! Slug is available and already set
1590 return nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001591 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001592}
1593
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001594// patchCallback is the agent's patch tool callback.
1595// It warms the codereview cache in the background.
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001596func (a *Agent) patchCallback(input claudetool.PatchInput, output llm.ToolOut) llm.ToolOut {
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001597 if a.codereview != nil {
1598 a.codereview.WarmTestCache(input.Path)
1599 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001600 return output
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001601}
1602
Earl Lee2e463fb2025-04-17 11:22:22 -07001603func (a *Agent) Ready() <-chan struct{} {
1604 return a.ready
1605}
1606
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001607// BranchPrefix returns the configured branch prefix
1608func (a *Agent) BranchPrefix() string {
1609 return a.config.BranchPrefix
1610}
1611
philip.zeyliger6d3de482025-06-10 19:38:14 -07001612// LinkToGitHub returns whether GitHub branch linking is enabled
1613func (a *Agent) LinkToGitHub() bool {
1614 return a.config.LinkToGitHub
1615}
1616
Earl Lee2e463fb2025-04-17 11:22:22 -07001617func (a *Agent) UserMessage(ctx context.Context, msg string) {
1618 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1619 a.inbox <- msg
1620}
1621
Earl Lee2e463fb2025-04-17 11:22:22 -07001622func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1623 return a.convo.CancelToolUse(toolUseID, cause)
1624}
1625
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001626func (a *Agent) CancelTurn(cause error) {
1627 a.cancelTurnMu.Lock()
1628 defer a.cancelTurnMu.Unlock()
1629 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001630 // Force state transition to cancelled state
1631 ctx := a.config.Context
1632 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001633 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001634 }
1635}
1636
1637func (a *Agent) Loop(ctxOuter context.Context) {
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001638 // Start port monitoring
1639 if a.portMonitor != nil && a.IsInContainer() {
1640 if err := a.portMonitor.Start(ctxOuter); err != nil {
1641 slog.WarnContext(ctxOuter, "Failed to start port monitor", "error", err)
1642 } else {
1643 slog.InfoContext(ctxOuter, "Port monitor started")
1644 }
1645 }
1646
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001647 // Set up cleanup when context is done
1648 defer func() {
1649 if a.mcpManager != nil {
1650 a.mcpManager.Close()
1651 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001652 if a.portMonitor != nil && a.IsInContainer() {
1653 a.portMonitor.Stop()
1654 }
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001655 }()
1656
Earl Lee2e463fb2025-04-17 11:22:22 -07001657 for {
1658 select {
1659 case <-ctxOuter.Done():
1660 return
1661 default:
1662 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001663 a.cancelTurnMu.Lock()
1664 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001665 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001666 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001667 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001668 a.cancelTurn = cancel
1669 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001670 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1671 if err != nil {
1672 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1673 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001674 cancel(nil)
1675 }
1676 }
1677}
1678
1679func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1680 if m.Timestamp.IsZero() {
1681 m.Timestamp = time.Now()
1682 }
1683
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001684 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1685 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1686 m.Content = m.ToolResult
1687 }
1688
Earl Lee2e463fb2025-04-17 11:22:22 -07001689 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1690 if m.EndOfTurn && m.Type == AgentMessageType {
1691 turnDuration := time.Since(a.startOfTurn)
1692 m.TurnDuration = &turnDuration
1693 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1694 }
1695
Earl Lee2e463fb2025-04-17 11:22:22 -07001696 a.mu.Lock()
1697 defer a.mu.Unlock()
1698 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001699 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001700 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001701
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001702 // Notify all subscribers
1703 for _, ch := range a.subscribers {
1704 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001705 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001706}
1707
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001708func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1709 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001710 if block {
1711 select {
1712 case <-ctx.Done():
1713 return m, ctx.Err()
1714 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001715 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001716 }
1717 }
1718 for {
1719 select {
1720 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001721 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001722 default:
1723 return m, nil
1724 }
1725 }
1726}
1727
Sean McCullough885a16a2025-04-30 02:49:25 +00001728// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001729func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001730 // Reset the start of turn time
1731 a.startOfTurn = time.Now()
1732
Sean McCullough96b60dd2025-04-30 09:49:10 -07001733 // Transition to waiting for user input state
1734 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1735
Sean McCullough885a16a2025-04-30 02:49:25 +00001736 // Process initial user message
1737 initialResp, err := a.processUserMessage(ctx)
1738 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001739 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001740 return err
1741 }
1742
1743 // Handle edge case where both initialResp and err are nil
1744 if initialResp == nil {
1745 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001746 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1747
Sean McCullough9f4b8082025-04-30 17:34:07 +00001748 a.pushToOutbox(ctx, errorMessage(err))
1749 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001750 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001751
Earl Lee2e463fb2025-04-17 11:22:22 -07001752 // We do this as we go, but let's also do it at the end of the turn
1753 defer func() {
1754 if _, err := a.handleGitCommits(ctx); err != nil {
1755 // Just log the error, don't stop execution
1756 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1757 }
1758 }()
1759
Sean McCullougha1e0e492025-05-01 10:51:08 -07001760 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001761 resp := initialResp
1762 for {
1763 // Check if we are over budget
1764 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001765 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001766 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001767 }
1768
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001769 // Check if we should compact the conversation
1770 if a.ShouldCompact() {
1771 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1772 if err := a.CompactConversation(ctx); err != nil {
1773 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1774 return err
1775 }
1776 // After compaction, end this turn and start fresh
1777 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1778 return nil
1779 }
1780
Sean McCullough885a16a2025-04-30 02:49:25 +00001781 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001782 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001783 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001784 break
1785 }
1786
Sean McCullough96b60dd2025-04-30 09:49:10 -07001787 // Transition to tool use requested state
1788 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1789
Sean McCullough885a16a2025-04-30 02:49:25 +00001790 // Handle tool execution
1791 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1792 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001793 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001794 }
1795
Sean McCullougha1e0e492025-05-01 10:51:08 -07001796 if toolResp == nil {
1797 return fmt.Errorf("cannot continue conversation with a nil tool response")
1798 }
1799
Sean McCullough885a16a2025-04-30 02:49:25 +00001800 // Set the response for the next iteration
1801 resp = toolResp
1802 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001803
1804 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001805}
1806
1807// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001808func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001809 // Wait for at least one message from the user
1810 msgs, err := a.GatherMessages(ctx, true)
1811 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001812 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001813 return nil, err
1814 }
1815
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001816 // Auto-generate slug if this is the first user input and no slug is set
1817 if a.Slug() == "" {
1818 if err := a.autoGenerateSlug(ctx, msgs); err != nil {
1819 // NB: it is possible that autoGenerateSlug set the slug during the process
1820 // of trying to generate a slug.
1821 // The fact that it returned an error means that we cannot use that slug.
1822 slog.WarnContext(ctx, "Failed to auto-generate slug", "error", err)
1823 // use the session id instead. ugly, but we need a slug, and this will be unique.
1824 a.SetSlug(a.SessionID())
1825 }
1826 // Notify termui of the final slug (only emitted once, after slug is determined)
1827 a.pushToOutbox(ctx, AgentMessage{
1828 Type: SlugMessageType,
1829 Content: a.Slug(),
1830 })
1831 }
1832
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001833 userMessage := llm.Message{
1834 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001835 Content: msgs,
1836 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001837
Sean McCullough96b60dd2025-04-30 09:49:10 -07001838 // Transition to sending to LLM state
1839 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1840
Sean McCullough885a16a2025-04-30 02:49:25 +00001841 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001842 resp, err := a.convo.SendMessage(userMessage)
1843 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001844 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001845 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001846 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001847 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001848
Sean McCullough96b60dd2025-04-30 09:49:10 -07001849 // Transition to processing LLM response state
1850 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1851
Sean McCullough885a16a2025-04-30 02:49:25 +00001852 return resp, nil
1853}
1854
1855// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001856func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1857 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001858 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001859 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001860
Sean McCullough96b60dd2025-04-30 09:49:10 -07001861 // Transition to checking for cancellation state
1862 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1863
Sean McCullough885a16a2025-04-30 02:49:25 +00001864 // Check if the operation was cancelled by the user
1865 select {
1866 case <-ctx.Done():
1867 // Don't actually run any of the tools, but rather build a response
1868 // for each tool_use message letting the LLM know that user canceled it.
1869 var err error
1870 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001871 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001872 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001873 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001874 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001875 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001876 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001877 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001878 // Transition to running tool state
1879 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1880
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001881 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001882 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001883 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001884
1885 // Execute the tools
1886 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001887 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001888 if ctx.Err() != nil { // e.g. the user canceled the operation
1889 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001890 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001891 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001892 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001893 a.pushToOutbox(ctx, errorMessage(err))
1894 }
1895 }
1896
1897 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001898 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001899 autoqualityMessages := a.processGitChanges(ctx)
1900
1901 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001902 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001903 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001904 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001905 return false, nil
1906 }
1907
1908 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001909 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1910 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001911}
1912
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001913// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001914func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001915 // Check for git commits
1916 _, err := a.handleGitCommits(ctx)
1917 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001918 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001919 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001920 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001921 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001922}
1923
1924// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1925// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001926func (a *Agent) processGitChanges(ctx context.Context) []string {
1927 // Check for git commits after tool execution
1928 newCommits, err := a.handleGitCommits(ctx)
1929 if err != nil {
1930 // Just log the error, don't stop execution
1931 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1932 return nil
1933 }
1934
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001935 // Run mechanical checks if there was exactly one new commit.
1936 if len(newCommits) != 1 {
1937 return nil
1938 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001939 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001940 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1941 msg := a.codereview.RunMechanicalChecks(ctx)
1942 if msg != "" {
1943 a.pushToOutbox(ctx, AgentMessage{
1944 Type: AutoMessageType,
1945 Content: msg,
1946 Timestamp: time.Now(),
1947 })
1948 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001949 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001950
1951 return autoqualityMessages
1952}
1953
1954// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001955func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001956 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001957 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001958 msgs, err := a.GatherMessages(ctx, false)
1959 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001960 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001961 return false, nil
1962 }
1963
1964 // Inject any auto-generated messages from quality checks
1965 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001966 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001967 }
1968
1969 // Handle cancellation by appending a message about it
1970 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001971 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001972 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001973 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001974 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1975 } else if err := a.convo.OverBudget(); err != nil {
1976 // Handle budget issues by appending a message about it
1977 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 -07001978 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001979 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1980 }
1981
1982 // Combine tool results with user messages
1983 results = append(results, msgs...)
1984
1985 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001986 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001987 resp, err := a.convo.SendMessage(llm.Message{
1988 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001989 Content: results,
1990 })
1991 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001992 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001993 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1994 return true, nil // Return true to continue the conversation, but with no response
1995 }
1996
Sean McCullough96b60dd2025-04-30 09:49:10 -07001997 // Transition back to processing LLM response
1998 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
1999
Sean McCullough885a16a2025-04-30 02:49:25 +00002000 if cancelled {
2001 return false, nil
2002 }
2003
2004 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07002005}
2006
2007func (a *Agent) overBudget(ctx context.Context) error {
2008 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07002009 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07002010 m := budgetMessage(err)
2011 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07002012 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07002013 a.convo.ResetBudget(a.originalBudget)
2014 return err
2015 }
2016 return nil
2017}
2018
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002019func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07002020 // Collect all text content
2021 var allText strings.Builder
2022 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002023 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07002024 if allText.Len() > 0 {
2025 allText.WriteString("\n\n")
2026 }
2027 allText.WriteString(content.Text)
2028 }
2029 }
2030 return allText.String()
2031}
2032
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002033func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07002034 a.mu.Lock()
2035 defer a.mu.Unlock()
2036 return a.convo.CumulativeUsage()
2037}
2038
Earl Lee2e463fb2025-04-17 11:22:22 -07002039// Diff returns a unified diff of changes made since the agent was instantiated.
2040func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07002041 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07002042 return "", fmt.Errorf("no initial commit reference available")
2043 }
2044
2045 // Find the repository root
2046 ctx := context.Background()
2047
2048 // If a specific commit hash is provided, show just that commit's changes
2049 if commit != nil && *commit != "" {
2050 // Validate that the commit looks like a valid git SHA
2051 if !isValidGitSHA(*commit) {
2052 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
2053 }
2054
2055 // Get the diff for just this commit
2056 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
2057 cmd.Dir = a.repoRoot
2058 output, err := cmd.CombinedOutput()
2059 if err != nil {
2060 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
2061 }
2062 return string(output), nil
2063 }
2064
2065 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07002066 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07002067 cmd.Dir = a.repoRoot
2068 output, err := cmd.CombinedOutput()
2069 if err != nil {
2070 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
2071 }
2072
2073 return string(output), nil
2074}
2075
Philip Zeyliger49edc922025-05-14 09:45:45 -07002076// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
2077// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
2078func (a *Agent) SketchGitBaseRef() string {
2079 if a.IsInContainer() {
2080 return "sketch-base"
2081 } else {
2082 return "sketch-base-" + a.SessionID()
2083 }
2084}
2085
2086// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
2087func (a *Agent) SketchGitBase() string {
2088 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
2089 cmd.Dir = a.repoRoot
2090 output, err := cmd.CombinedOutput()
2091 if err != nil {
2092 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
2093 return "HEAD"
2094 }
2095 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002096}
2097
Pokey Rule7a113622025-05-12 10:58:45 +01002098// removeGitHooks removes the Git hooks directory from the repository
2099func removeGitHooks(_ context.Context, repoPath string) error {
2100 hooksDir := filepath.Join(repoPath, ".git", "hooks")
2101
2102 // Check if hooks directory exists
2103 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
2104 // Directory doesn't exist, nothing to do
2105 return nil
2106 }
2107
2108 // Remove the hooks directory
2109 err := os.RemoveAll(hooksDir)
2110 if err != nil {
2111 return fmt.Errorf("failed to remove git hooks directory: %w", err)
2112 }
2113
2114 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00002115 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01002116 if err != nil {
2117 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
2118 }
2119
2120 return nil
2121}
2122
Philip Zeyligerf2872992025-05-22 10:35:28 -07002123func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002124 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002125 for _, msg := range msgs {
2126 a.pushToOutbox(ctx, msg)
2127 }
2128 return commits, error
2129}
2130
Earl Lee2e463fb2025-04-17 11:22:22 -07002131// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002132// under docker, new HEADs are pushed to a branch according to the slug.
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002133func (ags *AgentGitState) handleGitCommits(ctx context.Context, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002134 ags.mu.Lock()
2135 defer ags.mu.Unlock()
2136
2137 msgs := []AgentMessage{}
2138 if repoRoot == "" {
2139 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002140 }
2141
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002142 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07002143 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002144 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07002145 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002146 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002147 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07002148 }
2149 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002150 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07002151 }()
2152
Philip Zeyliger64f60462025-06-16 13:57:10 -07002153 // Compute diff stats from baseRef to HEAD when HEAD changes
2154 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
2155 // Log error but don't fail the entire operation
2156 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
2157 } else {
2158 // Set diff stats directly since we already hold the mutex
2159 ags.linesAdded = added
2160 ags.linesRemoved = removed
2161 }
2162
Earl Lee2e463fb2025-04-17 11:22:22 -07002163 // Get new commits. Because it's possible that the agent does rebases, fixups, and
2164 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
2165 // to the last 100 commits.
2166 var commits []*GitCommit
2167
2168 // Get commits since the initial commit
2169 // Format: <hash>\0<subject>\0<body>\0
2170 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
2171 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002172 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 -07002173 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07002174 output, err := cmd.Output()
2175 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002176 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07002177 }
2178
2179 // Parse git log output and filter out already seen commits
2180 parsedCommits := parseGitLog(string(output))
2181
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002182 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07002183
2184 // Filter out commits we've already seen
2185 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002186 if commit.Hash == sketch {
2187 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07002188 }
2189
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002190 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
2191 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07002192 continue
2193 }
2194
2195 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07002196 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07002197
2198 // Add to our list of new commits
2199 commits = append(commits, &commit)
2200 }
2201
Philip Zeyligerf2872992025-05-22 10:35:28 -07002202 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002203 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07002204 // 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 -07002205 sketchCommit = &GitCommit{}
2206 sketchCommit.Hash = sketch
2207 sketchCommit.Subject = "unknown"
2208 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07002209 }
2210
Earl Lee2e463fb2025-04-17 11:22:22 -07002211 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
2212 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
2213 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00002214
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002215 // 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 +00002216 var out []byte
2217 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002218 originalRetryNumber := ags.retryNumber
2219 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00002220 for retries := range 10 {
2221 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07002222 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002223 }
2224
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002225 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002226 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002227 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002228 out, err = cmd.CombinedOutput()
2229
2230 if err == nil {
2231 // Success! Break out of the retry loop
2232 break
2233 }
2234
2235 // Check if this is the "refusing to update checked out branch" error
2236 if !strings.Contains(string(out), "refusing to update checked out branch") {
2237 // This is a different error, so don't retry
2238 break
2239 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002240 }
2241
2242 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002243 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002244 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002245 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002246 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002247 if ags.retryNumber != originalRetryNumber {
2248 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002249 msgs = append(msgs, AgentMessage{
2250 Type: AutoMessageType,
2251 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002252 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 +00002253 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002254 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002255 }
2256 }
2257
2258 // If we found new commits, create a message
2259 if len(commits) > 0 {
2260 msg := AgentMessage{
2261 Type: CommitMessageType,
2262 Timestamp: time.Now(),
2263 Commits: commits,
2264 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002265 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002266 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002267 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002268}
2269
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002270func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002271 return strings.Map(func(r rune) rune {
2272 // lowercase
2273 if r >= 'A' && r <= 'Z' {
2274 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002275 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002276 // replace spaces with dashes
2277 if r == ' ' {
2278 return '-'
2279 }
2280 // allow alphanumerics and dashes
2281 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2282 return r
2283 }
2284 return -1
2285 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002286}
2287
2288// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2289// and returns an array of GitCommit structs.
2290func parseGitLog(output string) []GitCommit {
2291 var commits []GitCommit
2292
2293 // No output means no commits
2294 if len(output) == 0 {
2295 return commits
2296 }
2297
2298 // Split by NULL byte
2299 parts := strings.Split(output, "\x00")
2300
2301 // Process in triplets (hash, subject, body)
2302 for i := 0; i < len(parts); i++ {
2303 // Skip empty parts
2304 if parts[i] == "" {
2305 continue
2306 }
2307
2308 // This should be a hash
2309 hash := strings.TrimSpace(parts[i])
2310
2311 // Make sure we have at least a subject part available
2312 if i+1 >= len(parts) {
2313 break // No more parts available
2314 }
2315
2316 // Get the subject
2317 subject := strings.TrimSpace(parts[i+1])
2318
2319 // Get the body if available
2320 body := ""
2321 if i+2 < len(parts) {
2322 body = strings.TrimSpace(parts[i+2])
2323 }
2324
2325 // Skip to the next triplet
2326 i += 2
2327
2328 commits = append(commits, GitCommit{
2329 Hash: hash,
2330 Subject: subject,
2331 Body: body,
2332 })
2333 }
2334
2335 return commits
2336}
2337
2338func repoRoot(ctx context.Context, dir string) (string, error) {
2339 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2340 stderr := new(strings.Builder)
2341 cmd.Stderr = stderr
2342 cmd.Dir = dir
2343 out, err := cmd.Output()
2344 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002345 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002346 }
2347 return strings.TrimSpace(string(out)), nil
2348}
2349
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002350// upsertRemoteOrigin configures the origin remote to point to the given URL.
2351// If the origin remote exists, it updates the URL. If it doesn't exist, it adds it.
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002352//
2353// NOTE: Maybe we should use an "insteadOf" setting instead of changing the URL.
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002354func upsertRemoteOrigin(ctx context.Context, repoDir, remoteURL string) error {
2355 // Try to set the URL for existing origin remote
2356 cmd := exec.CommandContext(ctx, "git", "remote", "set-url", "origin", remoteURL)
2357 cmd.Dir = repoDir
2358 if _, err := cmd.CombinedOutput(); err == nil {
2359 // Success.
2360 return nil
2361 }
2362 // Origin doesn't exist; add it.
2363 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", remoteURL)
2364 cmd.Dir = repoDir
2365 if out, err := cmd.CombinedOutput(); err != nil {
2366 return fmt.Errorf("failed to add git remote origin: %s: %w", out, err)
2367 }
2368 return nil
2369}
2370
Earl Lee2e463fb2025-04-17 11:22:22 -07002371func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2372 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2373 stderr := new(strings.Builder)
2374 cmd.Stderr = stderr
2375 cmd.Dir = dir
2376 out, err := cmd.Output()
2377 if err != nil {
2378 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2379 }
2380 // TODO: validate that out is valid hex
2381 return strings.TrimSpace(string(out)), nil
2382}
2383
2384// isValidGitSHA validates if a string looks like a valid git SHA hash.
2385// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2386func isValidGitSHA(sha string) bool {
2387 // Git SHA must be a hexadecimal string with at least 4 characters
2388 if len(sha) < 4 || len(sha) > 40 {
2389 return false
2390 }
2391
2392 // Check if the string only contains hexadecimal characters
2393 for _, char := range sha {
2394 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2395 return false
2396 }
2397 }
2398
2399 return true
2400}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002401
Philip Zeyliger64f60462025-06-16 13:57:10 -07002402// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2403func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2404 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2405 cmd.Dir = repoRoot
2406 out, err := cmd.Output()
2407 if err != nil {
2408 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2409 }
2410
2411 var totalAdded, totalRemoved int
2412 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2413 for _, line := range lines {
2414 if line == "" {
2415 continue
2416 }
2417 parts := strings.Fields(line)
2418 if len(parts) < 2 {
2419 continue
2420 }
2421 // Format: <added>\t<removed>\t<filename>
2422 if added, err := strconv.Atoi(parts[0]); err == nil {
2423 totalAdded += added
2424 }
2425 if removed, err := strconv.Atoi(parts[1]); err == nil {
2426 totalRemoved += removed
2427 }
2428 }
2429
2430 return totalAdded, totalRemoved, nil
2431}
2432
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002433// systemPromptData contains the data used to render the system prompt template
2434type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002435 ClientGOOS string
2436 ClientGOARCH string
2437 WorkingDir string
2438 RepoRoot string
2439 InitialCommit string
2440 Codebase *onstart.Codebase
2441 UseSketchWIP bool
Philip Zeyligere67e3b62025-07-24 16:54:21 -07002442 InstallationNudge bool
David Crawshawc886ac52025-06-13 23:40:03 +00002443 Branch string
2444 SpecialInstruction string
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +00002445 Now string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002446}
2447
2448// renderSystemPrompt renders the system prompt template.
2449func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +00002450 nowFn := a.now
2451 if nowFn == nil {
2452 nowFn = time.Now
2453 }
2454 now := nowFn()
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002455 data := systemPromptData{
Philip Zeyligere67e3b62025-07-24 16:54:21 -07002456 ClientGOOS: a.config.ClientGOOS,
2457 ClientGOARCH: a.config.ClientGOARCH,
2458 WorkingDir: a.workingDir,
2459 RepoRoot: a.repoRoot,
2460 InitialCommit: a.SketchGitBase(),
2461 Codebase: a.codebase,
2462 UseSketchWIP: a.config.InDocker,
2463 InstallationNudge: a.config.InDocker,
Josh Bleecher Snyder9224eb02025-07-26 04:45:05 +00002464 Now: now.Format(time.DateOnly),
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002465 }
David Crawshawc886ac52025-06-13 23:40:03 +00002466 if now.Month() == time.September && now.Day() == 19 {
Josh Bleecher Snyder783ab312025-07-25 07:22:38 -07002467 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 +00002468 }
2469
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002470 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2471 if err != nil {
2472 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2473 }
2474 buf := new(strings.Builder)
2475 err = tmpl.Execute(buf, data)
2476 if err != nil {
2477 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2478 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002479 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002480 return buf.String()
2481}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002482
2483// StateTransitionIterator provides an iterator over state transitions.
2484type StateTransitionIterator interface {
2485 // Next blocks until a new state transition is available or context is done.
2486 // Returns nil if the context is cancelled.
2487 Next() *StateTransition
2488 // Close removes the listener and cleans up resources.
2489 Close()
2490}
2491
2492// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2493type StateTransitionIteratorImpl struct {
2494 agent *Agent
2495 ctx context.Context
2496 ch chan StateTransition
2497 unsubscribe func()
2498}
2499
2500// Next blocks until a new state transition is available or the context is cancelled.
2501func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2502 select {
2503 case <-s.ctx.Done():
2504 return nil
2505 case transition, ok := <-s.ch:
2506 if !ok {
2507 return nil
2508 }
2509 transitionCopy := transition
2510 return &transitionCopy
2511 }
2512}
2513
2514// Close removes the listener and cleans up resources.
2515func (s *StateTransitionIteratorImpl) Close() {
2516 if s.unsubscribe != nil {
2517 s.unsubscribe()
2518 s.unsubscribe = nil
2519 }
2520}
2521
2522// NewStateTransitionIterator returns an iterator that receives state transitions.
2523func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2524 a.mu.Lock()
2525 defer a.mu.Unlock()
2526
2527 // Create channel to receive state transitions
2528 ch := make(chan StateTransition, 10)
2529
2530 // Add a listener to the state machine
2531 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2532
2533 return &StateTransitionIteratorImpl{
2534 agent: a,
2535 ctx: ctx,
2536 ch: ch,
2537 unsubscribe: unsubscribe,
2538 }
2539}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002540
2541// setupGitHooks creates or updates git hooks in the specified working directory.
2542func setupGitHooks(workingDir string) error {
2543 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2544
2545 _, err := os.Stat(hooksDir)
2546 if os.IsNotExist(err) {
2547 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2548 }
2549 if err != nil {
2550 return fmt.Errorf("error checking git hooks directory: %w", err)
2551 }
2552
2553 // Define the post-commit hook content
2554 postCommitHook := `#!/bin/bash
2555echo "<post_commit_hook>"
2556echo "Please review this commit message and fix it if it is incorrect."
2557echo "This hook only echos the commit message; it does not modify it."
2558echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2559echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002560PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002561echo "</last_commit_message>"
2562echo "</post_commit_hook>"
2563`
2564
2565 // Define the prepare-commit-msg hook content
2566 prepareCommitMsgHook := `#!/bin/bash
2567# Add Co-Authored-By and Change-ID trailers to commit messages
2568# Check if these trailers already exist before adding them
2569
2570commit_file="$1"
2571COMMIT_SOURCE="$2"
2572
2573# Skip for merges, squashes, or when using a commit template
2574if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2575 [ "$COMMIT_SOURCE" = "squash" ]; then
2576 exit 0
2577fi
2578
2579commit_msg=$(cat "$commit_file")
2580
2581needs_co_author=true
2582needs_change_id=true
2583
2584# Check if commit message already has Co-Authored-By trailer
2585if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2586 needs_co_author=false
2587fi
2588
2589# Check if commit message already has Change-ID trailer
2590if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2591 needs_change_id=false
2592fi
2593
2594# Only modify if at least one trailer needs to be added
2595if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002596 # Ensure there's a proper blank line before trailers
2597 if [ -s "$commit_file" ]; then
2598 # Check if file ends with newline by reading last character
2599 last_char=$(tail -c 1 "$commit_file")
2600
2601 if [ "$last_char" != "" ]; then
2602 # File doesn't end with newline - add two newlines (complete line + blank line)
2603 echo "" >> "$commit_file"
2604 echo "" >> "$commit_file"
2605 else
2606 # File ends with newline - check if we already have a blank line
2607 last_line=$(tail -1 "$commit_file")
2608 if [ -n "$last_line" ]; then
2609 # Last line has content - add one newline for blank line
2610 echo "" >> "$commit_file"
2611 fi
2612 # If last line is empty, we already have a blank line - don't add anything
2613 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002614 fi
2615
2616 # Add trailers if needed
2617 if [ "$needs_co_author" = true ]; then
2618 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2619 fi
2620
2621 if [ "$needs_change_id" = true ]; then
2622 change_id=$(openssl rand -hex 8)
2623 echo "Change-ID: s${change_id}k" >> "$commit_file"
2624 fi
2625fi
2626`
2627
2628 // Update or create the post-commit hook
2629 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2630 if err != nil {
2631 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2632 }
2633
2634 // Update or create the prepare-commit-msg hook
2635 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2636 if err != nil {
2637 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2638 }
2639
2640 return nil
2641}
2642
2643// updateOrCreateHook creates a new hook file or updates an existing one
2644// by appending the new content if it doesn't already contain it.
2645func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2646 // Check if the hook already exists
2647 buf, err := os.ReadFile(hookPath)
2648 if os.IsNotExist(err) {
2649 // Hook doesn't exist, create it
2650 err = os.WriteFile(hookPath, []byte(content), 0o755)
2651 if err != nil {
2652 return fmt.Errorf("failed to create hook: %w", err)
2653 }
2654 return nil
2655 }
2656 if err != nil {
2657 return fmt.Errorf("error reading existing hook: %w", err)
2658 }
2659
2660 // Hook exists, check if our content is already in it by looking for a distinctive line
2661 code := string(buf)
2662 if strings.Contains(code, distinctiveLine) {
2663 // Already contains our content, nothing to do
2664 return nil
2665 }
2666
2667 // Append our content to the existing hook
2668 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2669 if err != nil {
2670 return fmt.Errorf("failed to open hook for appending: %w", err)
2671 }
2672 defer f.Close()
2673
2674 // Ensure there's a newline at the end of the existing content if needed
2675 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2676 _, err = f.WriteString("\n")
2677 if err != nil {
2678 return fmt.Errorf("failed to add newline to hook: %w", err)
2679 }
2680 }
2681
2682 // Add a separator before our content
2683 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2684 if err != nil {
2685 return fmt.Errorf("failed to append to hook: %w", err)
2686 }
2687
2688 return nil
2689}
Sean McCullough138ec242025-06-02 22:42:06 +00002690
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002691// configurePassthroughUpstream configures git remotes
2692// Adds an upstream remote pointing to the same as origin
2693// Sets the refspec for upstream and fetch such that both
2694// fetch the upstream's things into refs/remotes/upstream/foo
2695// The typical scenario is:
2696//
2697// github - laptop - sketch container
2698// "upstream" "origin"
2699func (a *Agent) configurePassthroughUpstream(ctx context.Context) error {
2700 // Get the origin remote URL
2701 cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
2702 cmd.Dir = a.workingDir
2703 originURLBytes, err := cmd.CombinedOutput()
2704 if err != nil {
2705 return fmt.Errorf("failed to get origin URL: %s: %w", originURLBytes, err)
2706 }
2707 originURL := strings.TrimSpace(string(originURLBytes))
2708
2709 // Check if upstream remote already exists
2710 cmd = exec.CommandContext(ctx, "git", "remote", "get-url", "upstream")
2711 cmd.Dir = a.workingDir
2712 if _, err := cmd.CombinedOutput(); err != nil {
2713 // upstream remote doesn't exist, create it
2714 cmd = exec.CommandContext(ctx, "git", "remote", "add", "upstream", originURL)
2715 cmd.Dir = a.workingDir
2716 if out, err := cmd.CombinedOutput(); err != nil {
2717 return fmt.Errorf("failed to add upstream remote: %s: %w", out, err)
2718 }
2719 slog.InfoContext(ctx, "added upstream remote", "url", originURL)
2720 } else {
2721 // upstream remote exists, update its URL
2722 cmd = exec.CommandContext(ctx, "git", "remote", "set-url", "upstream", originURL)
2723 cmd.Dir = a.workingDir
2724 if out, err := cmd.CombinedOutput(); err != nil {
2725 return fmt.Errorf("failed to set upstream remote URL: %s: %w", out, err)
2726 }
2727 slog.InfoContext(ctx, "updated upstream remote URL", "url", originURL)
2728 }
2729
2730 // Add the upstream refspec to the upstream remote
2731 cmd = exec.CommandContext(ctx, "git", "config", "remote.upstream.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2732 cmd.Dir = a.workingDir
2733 if out, err := cmd.CombinedOutput(); err != nil {
2734 return fmt.Errorf("failed to set upstream fetch refspec: %s: %w", out, err)
2735 }
2736
2737 // Add the same refspec to the origin remote
2738 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.origin.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2739 cmd.Dir = a.workingDir
2740 if out, err := cmd.CombinedOutput(); err != nil {
2741 return fmt.Errorf("failed to add upstream refspec to origin: %s: %w", out, err)
2742 }
2743
2744 slog.InfoContext(ctx, "configured passthrough upstream", "origin_url", originURL)
2745 return nil
2746}
2747
Philip Zeyliger0113be52025-06-07 23:53:41 +00002748// SkabandAddr returns the skaband address if configured
2749func (a *Agent) SkabandAddr() string {
2750 if a.config.SkabandClient != nil {
2751 return a.config.SkabandClient.Addr()
2752 }
2753 return ""
2754}
bankseanbdc68892025-07-28 17:28:13 -07002755
2756// ExternalMsg represents a message from a source external to the agent/user conversation,
2757// such as the outcome of a github workflow run.
2758type ExternalMessage struct {
2759 MessageType string `json:"message_type"`
2760 Body any `json:"body"`
2761 TextContent string `json:"text_content"`
2762}