blob: 85a4afe030970901840c3c1a0b4ee708f083ac07 [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,
Josh Bleecher Snyder994e9842025-07-30 20:26:47 -07001397 Simplified: llm.UseSimplifiedPatch(a.config.Service),
Josh Bleecher Snyder7f18fb62025-07-30 18:12:29 -07001398 ClipboardEnabled: experiment.Enabled("clipboard"),
Josh Bleecher Snyder04f16a52025-07-30 11:46:25 -07001399 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001400
Earl Lee2e463fb2025-04-17 11:22:22 -07001401 // Register all tools with the conversation
1402 // When adding, removing, or modifying tools here, double-check that the termui tool display
1403 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001404
1405 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001406 _, supportsScreenshots := a.config.Service.(*ant.Service)
1407 var bTools []*llm.Tool
1408 var browserCleanup func()
1409
1410 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1411 // Add cleanup function to context cancel
1412 go func() {
1413 <-a.config.Context.Done()
1414 browserCleanup()
1415 }()
1416 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001417
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001418 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd64bc912025-07-24 11:42:33 -07001419 bashTool.Tool(),
1420 claudetool.Keyword,
Josh Bleecher Snyder04f16a52025-07-30 11:46:25 -07001421 patchTool.Tool(),
Josh Bleecher Snyderd64bc912025-07-24 11:42:33 -07001422 claudetool.Think,
1423 claudetool.TodoRead,
1424 claudetool.TodoWrite,
1425 makeDoneTool(a.codereview),
1426 a.codereview.Tool(),
1427 claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001428 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001429 convo.Tools = append(convo.Tools, browserTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001430
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001431 // Add MCP tools if configured
1432 if len(a.config.MCPServers) > 0 {
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001433
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001434 slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers))
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001435 serverConfigs, parseErrors := mcp.ParseServerConfigs(ctx, a.config.MCPServers)
1436
1437 // Replace any headers with value _sketch_public_key_ and _sketch_session_id_ with those values.
1438 for i := range serverConfigs {
1439 if serverConfigs[i].Headers != nil {
1440 for key, value := range serverConfigs[i].Headers {
Philip Zeyligerf2814ea2025-06-30 10:16:50 -07001441 // Replace env placeholders. E.g., "env:FOO" becomes os.Getenv("FOO")
1442 if strings.HasPrefix(value, "env:") {
1443 serverConfigs[i].Headers[key] = os.Getenv(value[4:])
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001444 }
1445 }
1446 }
1447 }
Philip Zeyligerc540df72025-07-25 09:21:56 -07001448 mcpConnections, mcpErrors := a.mcpManager.ConnectToServerConfigs(ctx, serverConfigs, mcp.DefaultMCPConnectionTimeout, parseErrors)
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001449
1450 if len(mcpErrors) > 0 {
1451 for _, err := range mcpErrors {
1452 slog.ErrorContext(ctx, "MCP connection error", "error", err)
1453 // Send agent message about MCP connection failures
1454 a.pushToOutbox(ctx, AgentMessage{
1455 Type: ErrorMessageType,
1456 Content: fmt.Sprintf("MCP server connection failed: %v", err),
1457 })
1458 }
1459 }
1460
1461 if len(mcpConnections) > 0 {
1462 // Add tools from all successful connections
1463 totalTools := 0
1464 for _, connection := range mcpConnections {
1465 convo.Tools = append(convo.Tools, connection.Tools...)
1466 totalTools += len(connection.Tools)
1467 // Log tools per server using structured data
1468 slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames)
1469 }
1470 slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools)
1471 } else {
1472 slog.InfoContext(ctx, "No MCP tools available after connection attempts")
1473 }
1474 }
1475
Earl Lee2e463fb2025-04-17 11:22:22 -07001476 convo.Listener = a
1477 return convo
1478}
1479
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001480// branchExists reports whether branchName exists, either locally or in well-known remotes.
1481func branchExists(dir, branchName string) bool {
1482 refs := []string{
1483 "refs/heads/",
1484 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001485 }
1486 for _, ref := range refs {
1487 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1488 cmd.Dir = dir
1489 if cmd.Run() == nil { // exit code 0 means branch exists
1490 return true
1491 }
1492 }
1493 return false
1494}
1495
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001496func soleText(contents []llm.Content) (string, error) {
1497 if len(contents) != 1 {
1498 return "", fmt.Errorf("multiple contents %v", contents)
1499 }
1500 content := contents[0]
1501 if content.Type != llm.ContentTypeText || content.Text == "" {
1502 return "", fmt.Errorf("bad content %v", content)
1503 }
1504 return strings.TrimSpace(content.Text), nil
1505}
1506
1507// autoGenerateSlug automatically generates a slug based on the first user input
1508func (a *Agent) autoGenerateSlug(ctx context.Context, userContents []llm.Content) error {
1509 userText, err := soleText(userContents)
1510 if err != nil {
1511 return err
1512 }
1513 if userText == "" {
1514 return fmt.Errorf("set-slug: empty text content")
1515 }
1516
1517 // Create a subconversation without history for slug generation
1518 convo, ok := a.convo.(*conversation.Convo)
1519 if !ok {
1520 // In test environments, the conversation might be a mock interface
1521 // Skip slug generation in this case
1522 return fmt.Errorf("set-slug: can't make a subconvo (mock convo?)")
1523 }
1524
1525 // Loop until we find an acceptable slug
1526 var unavailableSlugs []string
1527 for {
1528 if len(unavailableSlugs) > 10 {
1529 // sanity check to prevent infinite loops
1530 return fmt.Errorf("set-slug: failed to construct a new slug after %d attempts", len(unavailableSlugs))
Earl Lee2e463fb2025-04-17 11:22:22 -07001531 }
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001532 subConvo := convo.SubConvo()
1533 subConvo.Hidden = true
1534
1535 // Prompt for slug generation
1536 prompt := `You are a slug generator for Sketch, an agentic coding environment.
1537The user's prompt will be in <user-prompt> tags. Any unavailable slugs will be listed in <unavailable-slug> tags.
1538Generate a 2-3 word alphanumeric hyphenated slug in imperative tense that captures the essence of their coding task.
1539Respond with only the slug.`
1540
1541 buf := new(strings.Builder)
1542 buf.WriteString("<slug-request>")
1543 if len(unavailableSlugs) > 0 {
1544 buf.WriteString("<unavailable-slugs>")
1545 }
1546 for _, x := range unavailableSlugs {
1547 buf.WriteString("<unavailable-slug>")
1548 buf.WriteString(x)
1549 buf.WriteString("</unavailable-slug>")
1550 }
1551 if len(unavailableSlugs) > 0 {
1552 buf.WriteString("</unavailable-slugs>")
1553 }
1554 buf.WriteString("<user-prompt>")
1555 buf.WriteString(userText)
1556 buf.WriteString("</user-prompt>")
1557 buf.WriteString("</slug-request>")
1558
1559 fullPrompt := prompt + "\n" + buf.String()
1560 userMessage := llm.UserStringMessage(fullPrompt)
1561
1562 resp, err := subConvo.SendMessage(userMessage)
1563 if err != nil {
1564 return fmt.Errorf("failed to generate slug: %w", err)
1565 }
1566
1567 // Extract the slug from the response
1568 slugText, err := soleText(resp.Content)
1569 if err != nil {
1570 return err
1571 }
1572 if slugText == "" {
1573 return fmt.Errorf("empty slug generated")
1574 }
1575
1576 // Clean and validate the slug
1577 slug := cleanSlugName(slugText)
1578 if slug == "" {
1579 return fmt.Errorf("slug could not be cleaned: %q", slugText)
1580 }
1581
1582 // Check if branch already exists using the same logic as the original set-slug tool
1583 a.SetSlug(slug) // Set slug first so BranchName() works correctly
1584 if branchExists(a.workingDir, a.BranchName()) {
1585 // try again
1586 unavailableSlugs = append(unavailableSlugs, slug)
1587 continue
1588 }
1589
1590 // Success! Slug is available and already set
1591 return nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001592 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001593}
1594
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001595// patchCallback is the agent's patch tool callback.
1596// It warms the codereview cache in the background.
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001597func (a *Agent) patchCallback(input claudetool.PatchInput, output llm.ToolOut) llm.ToolOut {
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001598 if a.codereview != nil {
1599 a.codereview.WarmTestCache(input.Path)
1600 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001601 return output
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001602}
1603
Earl Lee2e463fb2025-04-17 11:22:22 -07001604func (a *Agent) Ready() <-chan struct{} {
1605 return a.ready
1606}
1607
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001608// BranchPrefix returns the configured branch prefix
1609func (a *Agent) BranchPrefix() string {
1610 return a.config.BranchPrefix
1611}
1612
philip.zeyliger6d3de482025-06-10 19:38:14 -07001613// LinkToGitHub returns whether GitHub branch linking is enabled
1614func (a *Agent) LinkToGitHub() bool {
1615 return a.config.LinkToGitHub
1616}
1617
Earl Lee2e463fb2025-04-17 11:22:22 -07001618func (a *Agent) UserMessage(ctx context.Context, msg string) {
1619 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1620 a.inbox <- msg
1621}
1622
Earl Lee2e463fb2025-04-17 11:22:22 -07001623func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1624 return a.convo.CancelToolUse(toolUseID, cause)
1625}
1626
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001627func (a *Agent) CancelTurn(cause error) {
1628 a.cancelTurnMu.Lock()
1629 defer a.cancelTurnMu.Unlock()
1630 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001631 // Force state transition to cancelled state
1632 ctx := a.config.Context
1633 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001634 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001635 }
1636}
1637
1638func (a *Agent) Loop(ctxOuter context.Context) {
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001639 // Start port monitoring
1640 if a.portMonitor != nil && a.IsInContainer() {
1641 if err := a.portMonitor.Start(ctxOuter); err != nil {
1642 slog.WarnContext(ctxOuter, "Failed to start port monitor", "error", err)
1643 } else {
1644 slog.InfoContext(ctxOuter, "Port monitor started")
1645 }
1646 }
1647
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001648 // Set up cleanup when context is done
1649 defer func() {
1650 if a.mcpManager != nil {
1651 a.mcpManager.Close()
1652 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001653 if a.portMonitor != nil && a.IsInContainer() {
1654 a.portMonitor.Stop()
1655 }
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001656 }()
1657
Earl Lee2e463fb2025-04-17 11:22:22 -07001658 for {
1659 select {
1660 case <-ctxOuter.Done():
1661 return
1662 default:
1663 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001664 a.cancelTurnMu.Lock()
1665 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001666 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001667 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001668 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001669 a.cancelTurn = cancel
1670 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001671 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1672 if err != nil {
1673 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1674 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001675 cancel(nil)
1676 }
1677 }
1678}
1679
1680func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1681 if m.Timestamp.IsZero() {
1682 m.Timestamp = time.Now()
1683 }
1684
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001685 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1686 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1687 m.Content = m.ToolResult
1688 }
1689
Earl Lee2e463fb2025-04-17 11:22:22 -07001690 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1691 if m.EndOfTurn && m.Type == AgentMessageType {
1692 turnDuration := time.Since(a.startOfTurn)
1693 m.TurnDuration = &turnDuration
1694 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1695 }
1696
Earl Lee2e463fb2025-04-17 11:22:22 -07001697 a.mu.Lock()
1698 defer a.mu.Unlock()
1699 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001700 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001701 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001702
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001703 // Notify all subscribers
1704 for _, ch := range a.subscribers {
1705 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001706 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001707}
1708
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001709func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1710 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001711 if block {
1712 select {
1713 case <-ctx.Done():
1714 return m, ctx.Err()
1715 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001716 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001717 }
1718 }
1719 for {
1720 select {
1721 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001722 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001723 default:
1724 return m, nil
1725 }
1726 }
1727}
1728
Sean McCullough885a16a2025-04-30 02:49:25 +00001729// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001730func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001731 // Reset the start of turn time
1732 a.startOfTurn = time.Now()
1733
Sean McCullough96b60dd2025-04-30 09:49:10 -07001734 // Transition to waiting for user input state
1735 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1736
Sean McCullough885a16a2025-04-30 02:49:25 +00001737 // Process initial user message
1738 initialResp, err := a.processUserMessage(ctx)
1739 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001740 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001741 return err
1742 }
1743
1744 // Handle edge case where both initialResp and err are nil
1745 if initialResp == nil {
1746 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001747 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1748
Sean McCullough9f4b8082025-04-30 17:34:07 +00001749 a.pushToOutbox(ctx, errorMessage(err))
1750 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001751 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001752
Earl Lee2e463fb2025-04-17 11:22:22 -07001753 // We do this as we go, but let's also do it at the end of the turn
1754 defer func() {
1755 if _, err := a.handleGitCommits(ctx); err != nil {
1756 // Just log the error, don't stop execution
1757 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1758 }
1759 }()
1760
Sean McCullougha1e0e492025-05-01 10:51:08 -07001761 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001762 resp := initialResp
1763 for {
1764 // Check if we are over budget
1765 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001766 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001767 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001768 }
1769
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001770 // Check if we should compact the conversation
1771 if a.ShouldCompact() {
1772 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1773 if err := a.CompactConversation(ctx); err != nil {
1774 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1775 return err
1776 }
1777 // After compaction, end this turn and start fresh
1778 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1779 return nil
1780 }
1781
Sean McCullough885a16a2025-04-30 02:49:25 +00001782 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001783 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001784 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001785 break
1786 }
1787
Sean McCullough96b60dd2025-04-30 09:49:10 -07001788 // Transition to tool use requested state
1789 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1790
Sean McCullough885a16a2025-04-30 02:49:25 +00001791 // Handle tool execution
1792 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1793 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001794 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001795 }
1796
Sean McCullougha1e0e492025-05-01 10:51:08 -07001797 if toolResp == nil {
1798 return fmt.Errorf("cannot continue conversation with a nil tool response")
1799 }
1800
Sean McCullough885a16a2025-04-30 02:49:25 +00001801 // Set the response for the next iteration
1802 resp = toolResp
1803 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001804
1805 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001806}
1807
1808// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001809func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001810 // Wait for at least one message from the user
1811 msgs, err := a.GatherMessages(ctx, true)
1812 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001813 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001814 return nil, err
1815 }
1816
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001817 // Auto-generate slug if this is the first user input and no slug is set
1818 if a.Slug() == "" {
1819 if err := a.autoGenerateSlug(ctx, msgs); err != nil {
1820 // NB: it is possible that autoGenerateSlug set the slug during the process
1821 // of trying to generate a slug.
1822 // The fact that it returned an error means that we cannot use that slug.
1823 slog.WarnContext(ctx, "Failed to auto-generate slug", "error", err)
1824 // use the session id instead. ugly, but we need a slug, and this will be unique.
1825 a.SetSlug(a.SessionID())
1826 }
1827 // Notify termui of the final slug (only emitted once, after slug is determined)
1828 a.pushToOutbox(ctx, AgentMessage{
1829 Type: SlugMessageType,
1830 Content: a.Slug(),
1831 })
1832 }
1833
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001834 userMessage := llm.Message{
1835 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001836 Content: msgs,
1837 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001838
Sean McCullough96b60dd2025-04-30 09:49:10 -07001839 // Transition to sending to LLM state
1840 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1841
Sean McCullough885a16a2025-04-30 02:49:25 +00001842 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001843 resp, err := a.convo.SendMessage(userMessage)
1844 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001845 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001846 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001847 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001848 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001849
Sean McCullough96b60dd2025-04-30 09:49:10 -07001850 // Transition to processing LLM response state
1851 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1852
Sean McCullough885a16a2025-04-30 02:49:25 +00001853 return resp, nil
1854}
1855
1856// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001857func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1858 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001859 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001860 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001861
Sean McCullough96b60dd2025-04-30 09:49:10 -07001862 // Transition to checking for cancellation state
1863 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1864
Sean McCullough885a16a2025-04-30 02:49:25 +00001865 // Check if the operation was cancelled by the user
1866 select {
1867 case <-ctx.Done():
1868 // Don't actually run any of the tools, but rather build a response
1869 // for each tool_use message letting the LLM know that user canceled it.
1870 var err error
1871 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001872 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001873 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001874 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001875 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001876 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001877 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001878 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001879 // Transition to running tool state
1880 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1881
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001882 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001883 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001884 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001885
1886 // Execute the tools
1887 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001888 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001889 if ctx.Err() != nil { // e.g. the user canceled the operation
1890 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001891 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001892 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001893 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001894 a.pushToOutbox(ctx, errorMessage(err))
1895 }
1896 }
1897
1898 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001899 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001900 autoqualityMessages := a.processGitChanges(ctx)
1901
1902 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001903 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001904 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001905 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001906 return false, nil
1907 }
1908
1909 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001910 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1911 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001912}
1913
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001914// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001915func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001916 // Check for git commits
1917 _, err := a.handleGitCommits(ctx)
1918 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001919 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001920 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001921 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001922 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001923}
1924
1925// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1926// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001927func (a *Agent) processGitChanges(ctx context.Context) []string {
1928 // Check for git commits after tool execution
1929 newCommits, err := a.handleGitCommits(ctx)
1930 if err != nil {
1931 // Just log the error, don't stop execution
1932 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1933 return nil
1934 }
1935
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001936 // Run mechanical checks if there was exactly one new commit.
1937 if len(newCommits) != 1 {
1938 return nil
1939 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001940 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001941 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1942 msg := a.codereview.RunMechanicalChecks(ctx)
1943 if msg != "" {
1944 a.pushToOutbox(ctx, AgentMessage{
1945 Type: AutoMessageType,
1946 Content: msg,
1947 Timestamp: time.Now(),
1948 })
1949 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001950 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001951
1952 return autoqualityMessages
1953}
1954
1955// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001956func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001957 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001958 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001959 msgs, err := a.GatherMessages(ctx, false)
1960 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001961 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001962 return false, nil
1963 }
1964
1965 // Inject any auto-generated messages from quality checks
1966 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001967 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001968 }
1969
1970 // Handle cancellation by appending a message about it
1971 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001972 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001973 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001974 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001975 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1976 } else if err := a.convo.OverBudget(); err != nil {
1977 // Handle budget issues by appending a message about it
1978 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 -07001979 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001980 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1981 }
1982
1983 // Combine tool results with user messages
1984 results = append(results, msgs...)
1985
1986 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001987 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001988 resp, err := a.convo.SendMessage(llm.Message{
1989 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001990 Content: results,
1991 })
1992 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001993 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001994 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
1995 return true, nil // Return true to continue the conversation, but with no response
1996 }
1997
Sean McCullough96b60dd2025-04-30 09:49:10 -07001998 // Transition back to processing LLM response
1999 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
2000
Sean McCullough885a16a2025-04-30 02:49:25 +00002001 if cancelled {
2002 return false, nil
2003 }
2004
2005 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07002006}
2007
2008func (a *Agent) overBudget(ctx context.Context) error {
2009 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07002010 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07002011 m := budgetMessage(err)
2012 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07002013 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07002014 a.convo.ResetBudget(a.originalBudget)
2015 return err
2016 }
2017 return nil
2018}
2019
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002020func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07002021 // Collect all text content
2022 var allText strings.Builder
2023 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002024 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07002025 if allText.Len() > 0 {
2026 allText.WriteString("\n\n")
2027 }
2028 allText.WriteString(content.Text)
2029 }
2030 }
2031 return allText.String()
2032}
2033
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002034func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07002035 a.mu.Lock()
2036 defer a.mu.Unlock()
2037 return a.convo.CumulativeUsage()
2038}
2039
Earl Lee2e463fb2025-04-17 11:22:22 -07002040// Diff returns a unified diff of changes made since the agent was instantiated.
2041func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07002042 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07002043 return "", fmt.Errorf("no initial commit reference available")
2044 }
2045
2046 // Find the repository root
2047 ctx := context.Background()
2048
2049 // If a specific commit hash is provided, show just that commit's changes
2050 if commit != nil && *commit != "" {
2051 // Validate that the commit looks like a valid git SHA
2052 if !isValidGitSHA(*commit) {
2053 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
2054 }
2055
2056 // Get the diff for just this commit
2057 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
2058 cmd.Dir = a.repoRoot
2059 output, err := cmd.CombinedOutput()
2060 if err != nil {
2061 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
2062 }
2063 return string(output), nil
2064 }
2065
2066 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07002067 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07002068 cmd.Dir = a.repoRoot
2069 output, err := cmd.CombinedOutput()
2070 if err != nil {
2071 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
2072 }
2073
2074 return string(output), nil
2075}
2076
Philip Zeyliger49edc922025-05-14 09:45:45 -07002077// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
2078// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
2079func (a *Agent) SketchGitBaseRef() string {
2080 if a.IsInContainer() {
2081 return "sketch-base"
2082 } else {
2083 return "sketch-base-" + a.SessionID()
2084 }
2085}
2086
2087// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
2088func (a *Agent) SketchGitBase() string {
2089 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
2090 cmd.Dir = a.repoRoot
2091 output, err := cmd.CombinedOutput()
2092 if err != nil {
2093 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
2094 return "HEAD"
2095 }
2096 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002097}
2098
Pokey Rule7a113622025-05-12 10:58:45 +01002099// removeGitHooks removes the Git hooks directory from the repository
2100func removeGitHooks(_ context.Context, repoPath string) error {
2101 hooksDir := filepath.Join(repoPath, ".git", "hooks")
2102
2103 // Check if hooks directory exists
2104 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
2105 // Directory doesn't exist, nothing to do
2106 return nil
2107 }
2108
2109 // Remove the hooks directory
2110 err := os.RemoveAll(hooksDir)
2111 if err != nil {
2112 return fmt.Errorf("failed to remove git hooks directory: %w", err)
2113 }
2114
2115 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00002116 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01002117 if err != nil {
2118 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
2119 }
2120
2121 return nil
2122}
2123
Philip Zeyligerf2872992025-05-22 10:35:28 -07002124func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002125 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002126 for _, msg := range msgs {
2127 a.pushToOutbox(ctx, msg)
2128 }
2129 return commits, error
2130}
2131
Earl Lee2e463fb2025-04-17 11:22:22 -07002132// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002133// under docker, new HEADs are pushed to a branch according to the slug.
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002134func (ags *AgentGitState) handleGitCommits(ctx context.Context, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002135 ags.mu.Lock()
2136 defer ags.mu.Unlock()
2137
2138 msgs := []AgentMessage{}
2139 if repoRoot == "" {
2140 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002141 }
2142
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002143 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07002144 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002145 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07002146 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002147 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002148 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07002149 }
2150 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002151 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07002152 }()
2153
Philip Zeyliger64f60462025-06-16 13:57:10 -07002154 // Compute diff stats from baseRef to HEAD when HEAD changes
2155 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
2156 // Log error but don't fail the entire operation
2157 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
2158 } else {
2159 // Set diff stats directly since we already hold the mutex
2160 ags.linesAdded = added
2161 ags.linesRemoved = removed
2162 }
2163
Earl Lee2e463fb2025-04-17 11:22:22 -07002164 // Get new commits. Because it's possible that the agent does rebases, fixups, and
2165 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
2166 // to the last 100 commits.
2167 var commits []*GitCommit
2168
2169 // Get commits since the initial commit
2170 // Format: <hash>\0<subject>\0<body>\0
2171 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
2172 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002173 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 -07002174 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07002175 output, err := cmd.Output()
2176 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002177 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07002178 }
2179
2180 // Parse git log output and filter out already seen commits
2181 parsedCommits := parseGitLog(string(output))
2182
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002183 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07002184
2185 // Filter out commits we've already seen
2186 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002187 if commit.Hash == sketch {
2188 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07002189 }
2190
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002191 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
2192 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07002193 continue
2194 }
2195
2196 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07002197 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07002198
2199 // Add to our list of new commits
2200 commits = append(commits, &commit)
2201 }
2202
Philip Zeyligerf2872992025-05-22 10:35:28 -07002203 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002204 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07002205 // 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 -07002206 sketchCommit = &GitCommit{}
2207 sketchCommit.Hash = sketch
2208 sketchCommit.Subject = "unknown"
2209 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07002210 }
2211
Earl Lee2e463fb2025-04-17 11:22:22 -07002212 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
2213 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
2214 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00002215
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002216 // 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 +00002217 var out []byte
2218 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002219 originalRetryNumber := ags.retryNumber
2220 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00002221 for retries := range 10 {
2222 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07002223 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002224 }
2225
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002226 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002227 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002228 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002229 out, err = cmd.CombinedOutput()
2230
2231 if err == nil {
2232 // Success! Break out of the retry loop
2233 break
2234 }
2235
2236 // Check if this is the "refusing to update checked out branch" error
2237 if !strings.Contains(string(out), "refusing to update checked out branch") {
2238 // This is a different error, so don't retry
2239 break
2240 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002241 }
2242
2243 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002244 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002245 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002246 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002247 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002248 if ags.retryNumber != originalRetryNumber {
2249 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002250 msgs = append(msgs, AgentMessage{
2251 Type: AutoMessageType,
2252 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002253 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 +00002254 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002255 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002256 }
2257 }
2258
2259 // If we found new commits, create a message
2260 if len(commits) > 0 {
2261 msg := AgentMessage{
2262 Type: CommitMessageType,
2263 Timestamp: time.Now(),
2264 Commits: commits,
2265 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002266 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002267 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002268 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002269}
2270
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002271func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002272 return strings.Map(func(r rune) rune {
2273 // lowercase
2274 if r >= 'A' && r <= 'Z' {
2275 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002276 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002277 // replace spaces with dashes
2278 if r == ' ' {
2279 return '-'
2280 }
2281 // allow alphanumerics and dashes
2282 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2283 return r
2284 }
2285 return -1
2286 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002287}
2288
2289// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2290// and returns an array of GitCommit structs.
2291func parseGitLog(output string) []GitCommit {
2292 var commits []GitCommit
2293
2294 // No output means no commits
2295 if len(output) == 0 {
2296 return commits
2297 }
2298
2299 // Split by NULL byte
2300 parts := strings.Split(output, "\x00")
2301
2302 // Process in triplets (hash, subject, body)
2303 for i := 0; i < len(parts); i++ {
2304 // Skip empty parts
2305 if parts[i] == "" {
2306 continue
2307 }
2308
2309 // This should be a hash
2310 hash := strings.TrimSpace(parts[i])
2311
2312 // Make sure we have at least a subject part available
2313 if i+1 >= len(parts) {
2314 break // No more parts available
2315 }
2316
2317 // Get the subject
2318 subject := strings.TrimSpace(parts[i+1])
2319
2320 // Get the body if available
2321 body := ""
2322 if i+2 < len(parts) {
2323 body = strings.TrimSpace(parts[i+2])
2324 }
2325
2326 // Skip to the next triplet
2327 i += 2
2328
2329 commits = append(commits, GitCommit{
2330 Hash: hash,
2331 Subject: subject,
2332 Body: body,
2333 })
2334 }
2335
2336 return commits
2337}
2338
2339func repoRoot(ctx context.Context, dir string) (string, error) {
2340 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2341 stderr := new(strings.Builder)
2342 cmd.Stderr = stderr
2343 cmd.Dir = dir
2344 out, err := cmd.Output()
2345 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002346 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002347 }
2348 return strings.TrimSpace(string(out)), nil
2349}
2350
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002351// upsertRemoteOrigin configures the origin remote to point to the given URL.
2352// If the origin remote exists, it updates the URL. If it doesn't exist, it adds it.
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002353//
2354// NOTE: Maybe we should use an "insteadOf" setting instead of changing the URL.
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002355func upsertRemoteOrigin(ctx context.Context, repoDir, remoteURL string) error {
2356 // Try to set the URL for existing origin remote
2357 cmd := exec.CommandContext(ctx, "git", "remote", "set-url", "origin", remoteURL)
2358 cmd.Dir = repoDir
2359 if _, err := cmd.CombinedOutput(); err == nil {
2360 // Success.
2361 return nil
2362 }
2363 // Origin doesn't exist; add it.
2364 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", remoteURL)
2365 cmd.Dir = repoDir
2366 if out, err := cmd.CombinedOutput(); err != nil {
2367 return fmt.Errorf("failed to add git remote origin: %s: %w", out, err)
2368 }
2369 return nil
2370}
2371
Earl Lee2e463fb2025-04-17 11:22:22 -07002372func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2373 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2374 stderr := new(strings.Builder)
2375 cmd.Stderr = stderr
2376 cmd.Dir = dir
2377 out, err := cmd.Output()
2378 if err != nil {
2379 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2380 }
2381 // TODO: validate that out is valid hex
2382 return strings.TrimSpace(string(out)), nil
2383}
2384
2385// isValidGitSHA validates if a string looks like a valid git SHA hash.
2386// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2387func isValidGitSHA(sha string) bool {
2388 // Git SHA must be a hexadecimal string with at least 4 characters
2389 if len(sha) < 4 || len(sha) > 40 {
2390 return false
2391 }
2392
2393 // Check if the string only contains hexadecimal characters
2394 for _, char := range sha {
2395 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2396 return false
2397 }
2398 }
2399
2400 return true
2401}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002402
Philip Zeyliger64f60462025-06-16 13:57:10 -07002403// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2404func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2405 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2406 cmd.Dir = repoRoot
2407 out, err := cmd.Output()
2408 if err != nil {
2409 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2410 }
2411
2412 var totalAdded, totalRemoved int
2413 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2414 for _, line := range lines {
2415 if line == "" {
2416 continue
2417 }
2418 parts := strings.Fields(line)
2419 if len(parts) < 2 {
2420 continue
2421 }
2422 // Format: <added>\t<removed>\t<filename>
2423 if added, err := strconv.Atoi(parts[0]); err == nil {
2424 totalAdded += added
2425 }
2426 if removed, err := strconv.Atoi(parts[1]); err == nil {
2427 totalRemoved += removed
2428 }
2429 }
2430
2431 return totalAdded, totalRemoved, nil
2432}
2433
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002434// systemPromptData contains the data used to render the system prompt template
2435type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002436 ClientGOOS string
2437 ClientGOARCH string
2438 WorkingDir string
2439 RepoRoot string
2440 InitialCommit string
2441 Codebase *onstart.Codebase
2442 UseSketchWIP bool
Philip Zeyligere67e3b62025-07-24 16:54:21 -07002443 InstallationNudge bool
David Crawshawc886ac52025-06-13 23:40:03 +00002444 Branch string
2445 SpecialInstruction string
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +00002446 Now string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002447}
2448
2449// renderSystemPrompt renders the system prompt template.
2450func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +00002451 nowFn := a.now
2452 if nowFn == nil {
2453 nowFn = time.Now
2454 }
2455 now := nowFn()
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002456 data := systemPromptData{
Philip Zeyligere67e3b62025-07-24 16:54:21 -07002457 ClientGOOS: a.config.ClientGOOS,
2458 ClientGOARCH: a.config.ClientGOARCH,
2459 WorkingDir: a.workingDir,
2460 RepoRoot: a.repoRoot,
2461 InitialCommit: a.SketchGitBase(),
2462 Codebase: a.codebase,
2463 UseSketchWIP: a.config.InDocker,
2464 InstallationNudge: a.config.InDocker,
Josh Bleecher Snyder9224eb02025-07-26 04:45:05 +00002465 Now: now.Format(time.DateOnly),
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002466 }
David Crawshawc886ac52025-06-13 23:40:03 +00002467 if now.Month() == time.September && now.Day() == 19 {
Josh Bleecher Snyder783ab312025-07-25 07:22:38 -07002468 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 +00002469 }
2470
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002471 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2472 if err != nil {
2473 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2474 }
2475 buf := new(strings.Builder)
2476 err = tmpl.Execute(buf, data)
2477 if err != nil {
2478 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2479 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002480 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002481 return buf.String()
2482}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002483
2484// StateTransitionIterator provides an iterator over state transitions.
2485type StateTransitionIterator interface {
2486 // Next blocks until a new state transition is available or context is done.
2487 // Returns nil if the context is cancelled.
2488 Next() *StateTransition
2489 // Close removes the listener and cleans up resources.
2490 Close()
2491}
2492
2493// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2494type StateTransitionIteratorImpl struct {
2495 agent *Agent
2496 ctx context.Context
2497 ch chan StateTransition
2498 unsubscribe func()
2499}
2500
2501// Next blocks until a new state transition is available or the context is cancelled.
2502func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2503 select {
2504 case <-s.ctx.Done():
2505 return nil
2506 case transition, ok := <-s.ch:
2507 if !ok {
2508 return nil
2509 }
2510 transitionCopy := transition
2511 return &transitionCopy
2512 }
2513}
2514
2515// Close removes the listener and cleans up resources.
2516func (s *StateTransitionIteratorImpl) Close() {
2517 if s.unsubscribe != nil {
2518 s.unsubscribe()
2519 s.unsubscribe = nil
2520 }
2521}
2522
2523// NewStateTransitionIterator returns an iterator that receives state transitions.
2524func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2525 a.mu.Lock()
2526 defer a.mu.Unlock()
2527
2528 // Create channel to receive state transitions
2529 ch := make(chan StateTransition, 10)
2530
2531 // Add a listener to the state machine
2532 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2533
2534 return &StateTransitionIteratorImpl{
2535 agent: a,
2536 ctx: ctx,
2537 ch: ch,
2538 unsubscribe: unsubscribe,
2539 }
2540}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002541
2542// setupGitHooks creates or updates git hooks in the specified working directory.
2543func setupGitHooks(workingDir string) error {
2544 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2545
2546 _, err := os.Stat(hooksDir)
2547 if os.IsNotExist(err) {
2548 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2549 }
2550 if err != nil {
2551 return fmt.Errorf("error checking git hooks directory: %w", err)
2552 }
2553
2554 // Define the post-commit hook content
2555 postCommitHook := `#!/bin/bash
2556echo "<post_commit_hook>"
2557echo "Please review this commit message and fix it if it is incorrect."
2558echo "This hook only echos the commit message; it does not modify it."
2559echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2560echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002561PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002562echo "</last_commit_message>"
2563echo "</post_commit_hook>"
2564`
2565
2566 // Define the prepare-commit-msg hook content
2567 prepareCommitMsgHook := `#!/bin/bash
2568# Add Co-Authored-By and Change-ID trailers to commit messages
2569# Check if these trailers already exist before adding them
2570
2571commit_file="$1"
2572COMMIT_SOURCE="$2"
2573
2574# Skip for merges, squashes, or when using a commit template
2575if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2576 [ "$COMMIT_SOURCE" = "squash" ]; then
2577 exit 0
2578fi
2579
2580commit_msg=$(cat "$commit_file")
2581
2582needs_co_author=true
2583needs_change_id=true
2584
2585# Check if commit message already has Co-Authored-By trailer
2586if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2587 needs_co_author=false
2588fi
2589
2590# Check if commit message already has Change-ID trailer
2591if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2592 needs_change_id=false
2593fi
2594
2595# Only modify if at least one trailer needs to be added
2596if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002597 # Ensure there's a proper blank line before trailers
2598 if [ -s "$commit_file" ]; then
2599 # Check if file ends with newline by reading last character
2600 last_char=$(tail -c 1 "$commit_file")
2601
2602 if [ "$last_char" != "" ]; then
2603 # File doesn't end with newline - add two newlines (complete line + blank line)
2604 echo "" >> "$commit_file"
2605 echo "" >> "$commit_file"
2606 else
2607 # File ends with newline - check if we already have a blank line
2608 last_line=$(tail -1 "$commit_file")
2609 if [ -n "$last_line" ]; then
2610 # Last line has content - add one newline for blank line
2611 echo "" >> "$commit_file"
2612 fi
2613 # If last line is empty, we already have a blank line - don't add anything
2614 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002615 fi
2616
2617 # Add trailers if needed
2618 if [ "$needs_co_author" = true ]; then
2619 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2620 fi
2621
2622 if [ "$needs_change_id" = true ]; then
2623 change_id=$(openssl rand -hex 8)
2624 echo "Change-ID: s${change_id}k" >> "$commit_file"
2625 fi
2626fi
2627`
2628
2629 // Update or create the post-commit hook
2630 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2631 if err != nil {
2632 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2633 }
2634
2635 // Update or create the prepare-commit-msg hook
2636 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2637 if err != nil {
2638 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2639 }
2640
2641 return nil
2642}
2643
2644// updateOrCreateHook creates a new hook file or updates an existing one
2645// by appending the new content if it doesn't already contain it.
2646func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2647 // Check if the hook already exists
2648 buf, err := os.ReadFile(hookPath)
2649 if os.IsNotExist(err) {
2650 // Hook doesn't exist, create it
2651 err = os.WriteFile(hookPath, []byte(content), 0o755)
2652 if err != nil {
2653 return fmt.Errorf("failed to create hook: %w", err)
2654 }
2655 return nil
2656 }
2657 if err != nil {
2658 return fmt.Errorf("error reading existing hook: %w", err)
2659 }
2660
2661 // Hook exists, check if our content is already in it by looking for a distinctive line
2662 code := string(buf)
2663 if strings.Contains(code, distinctiveLine) {
2664 // Already contains our content, nothing to do
2665 return nil
2666 }
2667
2668 // Append our content to the existing hook
2669 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2670 if err != nil {
2671 return fmt.Errorf("failed to open hook for appending: %w", err)
2672 }
2673 defer f.Close()
2674
2675 // Ensure there's a newline at the end of the existing content if needed
2676 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2677 _, err = f.WriteString("\n")
2678 if err != nil {
2679 return fmt.Errorf("failed to add newline to hook: %w", err)
2680 }
2681 }
2682
2683 // Add a separator before our content
2684 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2685 if err != nil {
2686 return fmt.Errorf("failed to append to hook: %w", err)
2687 }
2688
2689 return nil
2690}
Sean McCullough138ec242025-06-02 22:42:06 +00002691
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002692// configurePassthroughUpstream configures git remotes
2693// Adds an upstream remote pointing to the same as origin
2694// Sets the refspec for upstream and fetch such that both
2695// fetch the upstream's things into refs/remotes/upstream/foo
2696// The typical scenario is:
2697//
2698// github - laptop - sketch container
2699// "upstream" "origin"
2700func (a *Agent) configurePassthroughUpstream(ctx context.Context) error {
2701 // Get the origin remote URL
2702 cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
2703 cmd.Dir = a.workingDir
2704 originURLBytes, err := cmd.CombinedOutput()
2705 if err != nil {
2706 return fmt.Errorf("failed to get origin URL: %s: %w", originURLBytes, err)
2707 }
2708 originURL := strings.TrimSpace(string(originURLBytes))
2709
2710 // Check if upstream remote already exists
2711 cmd = exec.CommandContext(ctx, "git", "remote", "get-url", "upstream")
2712 cmd.Dir = a.workingDir
2713 if _, err := cmd.CombinedOutput(); err != nil {
2714 // upstream remote doesn't exist, create it
2715 cmd = exec.CommandContext(ctx, "git", "remote", "add", "upstream", originURL)
2716 cmd.Dir = a.workingDir
2717 if out, err := cmd.CombinedOutput(); err != nil {
2718 return fmt.Errorf("failed to add upstream remote: %s: %w", out, err)
2719 }
2720 slog.InfoContext(ctx, "added upstream remote", "url", originURL)
2721 } else {
2722 // upstream remote exists, update its URL
2723 cmd = exec.CommandContext(ctx, "git", "remote", "set-url", "upstream", originURL)
2724 cmd.Dir = a.workingDir
2725 if out, err := cmd.CombinedOutput(); err != nil {
2726 return fmt.Errorf("failed to set upstream remote URL: %s: %w", out, err)
2727 }
2728 slog.InfoContext(ctx, "updated upstream remote URL", "url", originURL)
2729 }
2730
2731 // Add the upstream refspec to the upstream remote
2732 cmd = exec.CommandContext(ctx, "git", "config", "remote.upstream.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2733 cmd.Dir = a.workingDir
2734 if out, err := cmd.CombinedOutput(); err != nil {
2735 return fmt.Errorf("failed to set upstream fetch refspec: %s: %w", out, err)
2736 }
2737
2738 // Add the same refspec to the origin remote
2739 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.origin.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2740 cmd.Dir = a.workingDir
2741 if out, err := cmd.CombinedOutput(); err != nil {
2742 return fmt.Errorf("failed to add upstream refspec to origin: %s: %w", out, err)
2743 }
2744
2745 slog.InfoContext(ctx, "configured passthrough upstream", "origin_url", originURL)
2746 return nil
2747}
2748
Philip Zeyliger0113be52025-06-07 23:53:41 +00002749// SkabandAddr returns the skaband address if configured
2750func (a *Agent) SkabandAddr() string {
2751 if a.config.SkabandClient != nil {
2752 return a.config.SkabandClient.Addr()
2753 }
2754 return ""
2755}
bankseanbdc68892025-07-28 17:28:13 -07002756
2757// ExternalMsg represents a message from a source external to the agent/user conversation,
2758// such as the outcome of a github workflow run.
2759type ExternalMessage struct {
2760 MessageType string `json:"message_type"`
2761 Body any `json:"body"`
2762 TextContent string `json:"text_content"`
2763}