blob: decb4b07e90903ef333003efd6e57b5f3c5c5b34 [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"
gio30503072025-06-17 10:50:15 +000027 "sketch.dev/dodo_tools"
Giorgi Lekveishvili6a4ca202025-07-05 20:06:27 +040028<<<<<<< variant A
Josh Bleecher Snyder7f18fb62025-07-30 18:12:29 -070029 "sketch.dev/experiment"
Giorgi Lekveishvili6a4ca202025-07-05 20:06:27 +040030>>>>>>> variant B
31======= end
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070032 "sketch.dev/llm"
Philip Zeyliger72252cb2025-05-10 17:00:08 -070033 "sketch.dev/llm/ant"
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070034 "sketch.dev/llm/conversation"
Philip Zeyliger194bfa82025-06-24 06:03:06 -070035 "sketch.dev/mcp"
Philip Zeyligerc17ffe32025-06-05 19:49:13 -070036 "sketch.dev/skabandclient"
Philip Zeyliger5f26a342025-07-04 01:30:29 +000037 "tailscale.com/portlist"
Earl Lee2e463fb2025-04-17 11:22:22 -070038)
39
40const (
41 userCancelMessage = "user requested agent to stop handling responses"
42)
43
Philip Zeyligerb7c58752025-05-01 10:10:17 -070044type MessageIterator interface {
45 // Next blocks until the next message is available. It may
46 // return nil if the underlying iterator context is done.
47 Next() *AgentMessage
48 Close()
49}
50
Earl Lee2e463fb2025-04-17 11:22:22 -070051type CodingAgent interface {
52 // Init initializes an agent inside a docker container.
53 Init(AgentInit) error
54
55 // Ready returns a channel closed after Init successfully called.
56 Ready() <-chan struct{}
57
58 // URL reports the HTTP URL of this agent.
59 URL() string
60
61 // UserMessage enqueues a message to the agent and returns immediately.
62 UserMessage(ctx context.Context, msg string)
63
Philip Zeyligerb7c58752025-05-01 10:10:17 -070064 // Returns an iterator that finishes when the context is done and
65 // starts with the given message index.
66 NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator
Earl Lee2e463fb2025-04-17 11:22:22 -070067
Philip Zeyligereab12de2025-05-14 02:35:53 +000068 // Returns an iterator that notifies of state transitions until the context is done.
69 NewStateTransitionIterator(ctx context.Context) StateTransitionIterator
70
Earl Lee2e463fb2025-04-17 11:22:22 -070071 // Loop begins the agent loop returns only when ctx is cancelled.
72 Loop(ctx context.Context)
73
Philip Zeyligerbe7802a2025-06-04 20:15:25 +000074 // BranchPrefix returns the configured branch prefix
75 BranchPrefix() string
76
philip.zeyliger6d3de482025-06-10 19:38:14 -070077 // LinkToGitHub returns whether GitHub branch linking is enabled
78 LinkToGitHub() bool
79
Sean McCulloughedc88dc2025-04-30 02:55:01 +000080 CancelTurn(cause error)
Earl Lee2e463fb2025-04-17 11:22:22 -070081
82 CancelToolUse(toolUseID string, cause error) error
83
84 // Returns a subset of the agent's message history.
85 Messages(start int, end int) []AgentMessage
86
87 // Returns the current number of messages in the history
88 MessageCount() int
89
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -070090 TotalUsage() conversation.CumulativeUsage
91 OriginalBudget() conversation.Budget
Earl Lee2e463fb2025-04-17 11:22:22 -070092
Earl Lee2e463fb2025-04-17 11:22:22 -070093 WorkingDir() string
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +000094 RepoRoot() string
Earl Lee2e463fb2025-04-17 11:22:22 -070095
96 // Diff returns a unified diff of changes made since the agent was instantiated.
97 // If commit is non-nil, it shows the diff for just that specific commit.
98 Diff(commit *string) (string, error)
99
Philip Zeyliger49edc922025-05-14 09:45:45 -0700100 // SketchGitBase returns the commit that's the "base" for Sketch's work. It
101 // starts out as the commit where sketch started, but a user can move it if need
102 // be, for example in the case of a rebase. It is stored as a git tag.
103 SketchGitBase() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700104
Philip Zeyligerd3ac1122025-05-14 02:54:18 +0000105 // SketchGitBase returns the symbolic name for the "base" for Sketch's work.
106 // (Typically, this is "sketch-base")
107 SketchGitBaseRef() string
108
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700109 // Slug returns the slug identifier for this session.
110 Slug() string
Earl Lee2e463fb2025-04-17 11:22:22 -0700111
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000112 // BranchName returns the git branch name for the conversation.
113 BranchName() string
114
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700115 // IncrementRetryNumber increments the retry number for branch naming conflicts.
116 IncrementRetryNumber()
117
Earl Lee2e463fb2025-04-17 11:22:22 -0700118 // OS returns the operating system of the client.
119 OS() string
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000120
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000121 // SessionID returns the unique session identifier.
122 SessionID() string
123
philip.zeyliger8773e682025-06-11 21:36:21 -0700124 // SSHConnectionString returns the SSH connection string for the container.
125 SSHConnectionString() string
126
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000127 // DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -0700128 DetectGitChanges(ctx context.Context) error
Philip Zeyliger75bd37d2025-05-22 18:49:14 +0000129
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000130 // OutstandingLLMCallCount returns the number of outstanding LLM calls.
131 OutstandingLLMCallCount() int
132
133 // OutstandingToolCalls returns the names of outstanding tool calls.
134 OutstandingToolCalls() []string
Philip Zeyliger18532b22025-04-23 21:11:46 +0000135 OutsideOS() string
136 OutsideHostname() string
137 OutsideWorkingDir() string
Philip Zeyligerd1402952025-04-23 03:54:37 +0000138 GitOrigin() string
Philip Zeyliger64f60462025-06-16 13:57:10 -0700139
bankseancad67b02025-06-27 21:57:05 +0000140 // GitUsername returns the git user name from the agent config.
141 GitUsername() string
142
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700143 // PassthroughUpstream returns whether passthrough upstream is enabled.
144 PassthroughUpstream() bool
145
Philip Zeyliger64f60462025-06-16 13:57:10 -0700146 // DiffStats returns the number of lines added and removed from sketch-base to HEAD
147 DiffStats() (int, int)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000148 // OpenBrowser is a best-effort attempt to open a browser at url in outside sketch.
149 OpenBrowser(url string)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700150
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700151 // IsInContainer returns true if the agent is running in a container
152 IsInContainer() bool
153 // FirstMessageIndex returns the index of the first message in the current conversation
154 FirstMessageIndex() int
Sean McCulloughd9d45812025-04-30 16:53:41 -0700155
156 CurrentStateName() string
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700157 // CurrentTodoContent returns the current todo list data as JSON, or empty string if no todos exist
158 CurrentTodoContent() string
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700159
160 // CompactConversation compacts the current conversation by generating a summary
161 // and restarting the conversation with that summary as the initial context
162 CompactConversation(ctx context.Context) error
Philip Zeyligerda623b52025-07-04 01:12:38 +0000163
Philip Zeyliger0113be52025-06-07 23:53:41 +0000164 // SkabandAddr returns the skaband address if configured
165 SkabandAddr() string
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000166
167 // GetPorts returns the cached list of open TCP ports
168 GetPorts() []portlist.Port
banksean5ab8fb82025-07-09 12:34:55 -0700169
170 // TokenContextWindow returns the TokenContextWindow size of the model the agent is using.
171 TokenContextWindow() int
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000172
173 // ModelName returns the name of the model the agent is using.
174 ModelName() string
bankseanbdc68892025-07-28 17:28:13 -0700175
176 // ExternalMessage enqueues an external message to the agent and returns immediately.
177 ExternalMessage(ctx context.Context, msg ExternalMessage) error
Earl Lee2e463fb2025-04-17 11:22:22 -0700178}
179
180type CodingAgentMessageType string
181
182const (
bankseanbdc68892025-07-28 17:28:13 -0700183 UserMessageType CodingAgentMessageType = "user"
184 AgentMessageType CodingAgentMessageType = "agent"
185 ErrorMessageType CodingAgentMessageType = "error"
186 BudgetMessageType CodingAgentMessageType = "budget" // dedicated for "out of budget" errors
187 ToolUseMessageType CodingAgentMessageType = "tool"
188 CommitMessageType CodingAgentMessageType = "commit" // for displaying git commits
189 AutoMessageType CodingAgentMessageType = "auto" // for automated notifications like autoformatting
190 CompactMessageType CodingAgentMessageType = "compact" // for conversation compaction notifications
191 PortMessageType CodingAgentMessageType = "port" // for port monitoring events
192 SlugMessageType CodingAgentMessageType = "slug" // for slug updates
193 ExternalMessageType CodingAgentMessageType = "external" // for external notifications
Earl Lee2e463fb2025-04-17 11:22:22 -0700194
195 cancelToolUseMessage = "Stop responding to my previous message. Wait for me to ask you something else before attempting to use any more tools."
196)
197
198type AgentMessage struct {
199 Type CodingAgentMessageType `json:"type"`
200 // EndOfTurn indicates that the AI is done working and is ready for the next user input.
201 EndOfTurn bool `json:"end_of_turn"`
202
bankseanbdc68892025-07-28 17:28:13 -0700203 Content string `json:"content"`
204 ExternalMessage *ExternalMessage `json:"external_message,omitempty"`
205 ToolName string `json:"tool_name,omitempty"`
206 ToolInput string `json:"input,omitempty"`
207 ToolResult string `json:"tool_result,omitempty"`
208 ToolError bool `json:"tool_error,omitempty"`
209 ToolCallId string `json:"tool_call_id,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700210
211 // ToolCalls is a list of all tool calls requested in this message (name and input pairs)
212 ToolCalls []ToolCall `json:"tool_calls,omitempty"`
213
Sean McCulloughd9f13372025-04-21 15:08:49 -0700214 // ToolResponses is a list of all responses to tool calls requested in this message (name and input pairs)
215 ToolResponses []AgentMessage `json:"toolResponses,omitempty"`
216
Earl Lee2e463fb2025-04-17 11:22:22 -0700217 // Commits is a list of git commits for a commit message
218 Commits []*GitCommit `json:"commits,omitempty"`
219
220 Timestamp time.Time `json:"timestamp"`
221 ConversationID string `json:"conversation_id"`
222 ParentConversationID *string `json:"parent_conversation_id,omitempty"`
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700223 Usage *llm.Usage `json:"usage,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700224
225 // Message timing information
226 StartTime *time.Time `json:"start_time,omitempty"`
227 EndTime *time.Time `json:"end_time,omitempty"`
228 Elapsed *time.Duration `json:"elapsed,omitempty"`
229
230 // Turn duration - the time taken for a complete agent turn
231 TurnDuration *time.Duration `json:"turnDuration,omitempty"`
232
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000233 // HideOutput indicates that this message should not be rendered in the UI.
234 // This is useful for subconversations that generate output that shouldn't be shown to the user.
235 HideOutput bool `json:"hide_output,omitempty"`
236
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700237 // TodoContent contains the agent's todo file content when it has changed
238 TodoContent *string `json:"todo_content,omitempty"`
239
Josh Bleecher Snyder3dd3e412025-07-22 20:32:03 -0700240 // Display contains content to be displayed to the user, set by tools
241 Display any `json:"display,omitempty"`
242
Earl Lee2e463fb2025-04-17 11:22:22 -0700243 Idx int `json:"idx"`
244}
245
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000246// SetConvo sets m.ConversationID, m.ParentConversationID, and m.HideOutput based on convo.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700247func (m *AgentMessage) SetConvo(convo *conversation.Convo) {
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700248 if convo == nil {
249 m.ConversationID = ""
250 m.ParentConversationID = nil
251 return
252 }
253 m.ConversationID = convo.ID
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +0000254 m.HideOutput = convo.Hidden
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700255 if convo.Parent != nil {
256 m.ParentConversationID = &convo.Parent.ID
257 }
258}
259
Earl Lee2e463fb2025-04-17 11:22:22 -0700260// GitCommit represents a single git commit for a commit message
261type GitCommit struct {
262 Hash string `json:"hash"` // Full commit hash
263 Subject string `json:"subject"` // Commit subject line
264 Body string `json:"body"` // Full commit message body
265 PushedBranch string `json:"pushed_branch,omitempty"` // If set, this commit was pushed to this branch
266}
267
268// ToolCall represents a single tool call within an agent message
269type ToolCall struct {
Sean McCulloughd9f13372025-04-21 15:08:49 -0700270 Name string `json:"name"`
271 Input string `json:"input"`
272 ToolCallId string `json:"tool_call_id"`
273 ResultMessage *AgentMessage `json:"result_message,omitempty"`
274 Args string `json:"args,omitempty"`
275 Result string `json:"result,omitempty"`
Earl Lee2e463fb2025-04-17 11:22:22 -0700276}
277
278func (a *AgentMessage) Attr() slog.Attr {
279 var attrs []any = []any{
280 slog.String("type", string(a.Type)),
281 }
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700282 attrs = append(attrs, slog.Int("idx", a.Idx))
Earl Lee2e463fb2025-04-17 11:22:22 -0700283 if a.EndOfTurn {
284 attrs = append(attrs, slog.Bool("end_of_turn", a.EndOfTurn))
285 }
286 if a.Content != "" {
287 attrs = append(attrs, slog.String("content", a.Content))
288 }
289 if a.ToolName != "" {
290 attrs = append(attrs, slog.String("tool_name", a.ToolName))
291 }
292 if a.ToolInput != "" {
293 attrs = append(attrs, slog.String("tool_input", a.ToolInput))
294 }
295 if a.Elapsed != nil {
296 attrs = append(attrs, slog.Int64("elapsed", a.Elapsed.Nanoseconds()))
297 }
298 if a.TurnDuration != nil {
299 attrs = append(attrs, slog.Int64("turnDuration", a.TurnDuration.Nanoseconds()))
300 }
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700301 if len(a.ToolResult) > 0 {
302 attrs = append(attrs, slog.Any("tool_result", a.ToolResult))
Earl Lee2e463fb2025-04-17 11:22:22 -0700303 }
304 if a.ToolError {
305 attrs = append(attrs, slog.Bool("tool_error", a.ToolError))
306 }
307 if len(a.ToolCalls) > 0 {
308 toolCallAttrs := make([]any, 0, len(a.ToolCalls))
309 for i, tc := range a.ToolCalls {
310 toolCallAttrs = append(toolCallAttrs, slog.Group(
311 fmt.Sprintf("tool_call_%d", i),
312 slog.String("name", tc.Name),
313 slog.String("input", tc.Input),
314 ))
315 }
316 attrs = append(attrs, slog.Group("tool_calls", toolCallAttrs...))
317 }
318 if a.ConversationID != "" {
319 attrs = append(attrs, slog.String("convo_id", a.ConversationID))
320 }
321 if a.ParentConversationID != nil {
322 attrs = append(attrs, slog.String("parent_convo_id", *a.ParentConversationID))
323 }
324 if a.Usage != nil && !a.Usage.IsZero() {
325 attrs = append(attrs, a.Usage.Attr())
326 }
327 // TODO: timestamp, convo ids, idx?
328 return slog.Group("agent_message", attrs...)
329}
330
331func errorMessage(err error) AgentMessage {
332 // It's somewhat unknowable whether error messages are "end of turn" or not, but it seems like the best approach.
333 if os.Getenv(("DEBUG")) == "1" {
334 return AgentMessage{Type: ErrorMessageType, Content: err.Error() + " Stacktrace: " + string(debug.Stack()), EndOfTurn: true}
335 }
336
337 return AgentMessage{Type: ErrorMessageType, Content: err.Error(), EndOfTurn: true}
338}
339
340func budgetMessage(err error) AgentMessage {
341 return AgentMessage{Type: BudgetMessageType, Content: err.Error(), EndOfTurn: true}
342}
343
344// ConvoInterface defines the interface for conversation interactions
345type ConvoInterface interface {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700346 CumulativeUsage() conversation.CumulativeUsage
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700347 LastUsage() llm.Usage
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700348 ResetBudget(conversation.Budget)
Earl Lee2e463fb2025-04-17 11:22:22 -0700349 OverBudget() error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700350 SendMessage(message llm.Message) (*llm.Response, error)
351 SendUserTextMessage(s string, otherContents ...llm.Content) (*llm.Response, error)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700352 GetID() string
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +0000353 ToolResultContents(ctx context.Context, resp *llm.Response) ([]llm.Content, bool, error)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700354 ToolResultCancelContents(resp *llm.Response) ([]llm.Content, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700355 CancelToolUse(toolUseID string, cause error) error
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700356 SubConvoWithHistory() *conversation.Convo
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700357 DebugJSON() ([]byte, error)
Earl Lee2e463fb2025-04-17 11:22:22 -0700358}
359
Philip Zeyligerf2872992025-05-22 10:35:28 -0700360// AgentGitState holds the state necessary for pushing to a remote git repo
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700361// when sketch branch changes. If gitRemoteAddr is set, then we push to sketch/
Philip Zeyligerf2872992025-05-22 10:35:28 -0700362// any time we notice we need to.
363type AgentGitState struct {
364 mu sync.Mutex // protects following
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -0700365 lastSketch string // hash of the last sketch branch that was pushed to the host
Philip Zeyligerf2872992025-05-22 10:35:28 -0700366 gitRemoteAddr string // HTTP URL of the host git repo
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000367 upstream string // upstream branch for git work
Philip Zeyligerf2872992025-05-22 10:35:28 -0700368 seenCommits map[string]bool // Track git commits we've already seen (by hash)
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700369 slug string // Human-readable session identifier
370 retryNumber int // Number to append when branch conflicts occur
Philip Zeyliger64f60462025-06-16 13:57:10 -0700371 linesAdded int // Lines added from sketch-base to HEAD
372 linesRemoved int // Lines removed from sketch-base to HEAD
Philip Zeyligerf2872992025-05-22 10:35:28 -0700373}
374
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700375func (ags *AgentGitState) SetSlug(slug string) {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700376 ags.mu.Lock()
377 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700378 if ags.slug != slug {
379 ags.retryNumber = 0
380 }
381 ags.slug = slug
Philip Zeyligerf2872992025-05-22 10:35:28 -0700382}
383
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700384func (ags *AgentGitState) Slug() string {
Philip Zeyligerf2872992025-05-22 10:35:28 -0700385 ags.mu.Lock()
386 defer ags.mu.Unlock()
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700387 return ags.slug
388}
389
390func (ags *AgentGitState) IncrementRetryNumber() {
391 ags.mu.Lock()
392 defer ags.mu.Unlock()
393 ags.retryNumber++
394}
395
Philip Zeyliger64f60462025-06-16 13:57:10 -0700396func (ags *AgentGitState) DiffStats() (int, int) {
397 ags.mu.Lock()
398 defer ags.mu.Unlock()
399 return ags.linesAdded, ags.linesRemoved
400}
401
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700402// HasSeenCommits returns true if any commits have been processed
403func (ags *AgentGitState) HasSeenCommits() bool {
404 ags.mu.Lock()
405 defer ags.mu.Unlock()
406 return len(ags.seenCommits) > 0
407}
408
409func (ags *AgentGitState) RetryNumber() int {
410 ags.mu.Lock()
411 defer ags.mu.Unlock()
412 return ags.retryNumber
413}
414
415func (ags *AgentGitState) BranchName(prefix string) string {
416 ags.mu.Lock()
417 defer ags.mu.Unlock()
418 return ags.branchNameLocked(prefix)
419}
420
421func (ags *AgentGitState) branchNameLocked(prefix string) string {
422 if ags.slug == "" {
423 return ""
424 }
425 if ags.retryNumber == 0 {
426 return prefix + ags.slug
427 }
428 return fmt.Sprintf("%s%s%d", prefix, ags.slug, ags.retryNumber)
Philip Zeyligerf2872992025-05-22 10:35:28 -0700429}
430
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +0000431func (ags *AgentGitState) Upstream() string {
432 ags.mu.Lock()
433 defer ags.mu.Unlock()
434 return ags.upstream
435}
436
Earl Lee2e463fb2025-04-17 11:22:22 -0700437type Agent struct {
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700438 convo ConvoInterface
439 config AgentConfig // config for this agent
Philip Zeyligerf2872992025-05-22 10:35:28 -0700440 gitState AgentGitState
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700441 workingDir string
442 repoRoot string // workingDir may be a subdir of repoRoot
443 url string
444 firstMessageIndex int // index of the first message in the current conversation
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000445 outsideHTTP string // base address of the outside webserver (only when under docker)
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700446 ready chan struct{} // closed when the agent is initialized (only when under docker)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +0000447 codebase *onstart.Codebase
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700448 startedAt time.Time
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700449 originalBudget conversation.Budget
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +0000450 codereview *codereview.CodeReviewer
Sean McCullough96b60dd2025-04-30 09:49:10 -0700451 // State machine to track agent state
452 stateMachine *StateMachine
Philip Zeyliger18532b22025-04-23 21:11:46 +0000453 // Outside information
454 outsideHostname string
455 outsideOS string
456 outsideWorkingDir string
Philip Zeyliger194bfa82025-06-24 06:03:06 -0700457 // MCP manager for handling MCP server connections
458 mcpManager *mcp.MCPManager
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000459 // Port monitor for tracking TCP ports
460 portMonitor *PortMonitor
Earl Lee2e463fb2025-04-17 11:22:22 -0700461
462 // Time when the current turn started (reset at the beginning of InnerLoop)
463 startOfTurn time.Time
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +0000464 now func() time.Time // override-able, defaults to time.Now
Earl Lee2e463fb2025-04-17 11:22:22 -0700465
466 // Inbox - for messages from the user to the agent.
467 // sent on by UserMessage
468 // . e.g. when user types into the chat textarea
469 // read from by GatherMessages
470 inbox chan string
471
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000472 // protects cancelTurn
473 cancelTurnMu sync.Mutex
Earl Lee2e463fb2025-04-17 11:22:22 -0700474 // cancels potentially long-running tool_use calls or chains of them
Sean McCulloughedc88dc2025-04-30 02:55:01 +0000475 cancelTurn context.CancelCauseFunc
Earl Lee2e463fb2025-04-17 11:22:22 -0700476
477 // protects following
478 mu sync.Mutex
479
480 // Stores all messages for this agent
481 history []AgentMessage
482
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700483 // Iterators add themselves here when they're ready to be notified of new messages.
484 subscribers []chan *AgentMessage
Earl Lee2e463fb2025-04-17 11:22:22 -0700485
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000486 // Track outstanding LLM call IDs
487 outstandingLLMCalls map[string]struct{}
488
489 // Track outstanding tool calls by ID with their names
490 outstandingToolCalls map[string]string
Earl Lee2e463fb2025-04-17 11:22:22 -0700491}
492
bankseanbdc68892025-07-28 17:28:13 -0700493// ExternalMessage implements CodingAgent.
494// TODO: Debounce and/or coalesce these messages so they're less disruptive to the conversation.
495func (a *Agent) ExternalMessage(ctx context.Context, msg ExternalMessage) error {
496 agentMsg := AgentMessage{
497 Type: ExternalMessageType,
498 ExternalMessage: &msg,
499 }
500 a.pushToOutbox(ctx, agentMsg)
501 a.inbox <- msg.TextContent
502 return nil
503}
504
banksean5ab8fb82025-07-09 12:34:55 -0700505// TokenContextWindow implements CodingAgent.
506func (a *Agent) TokenContextWindow() int {
507 return a.config.Service.TokenContextWindow()
508}
509
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +0000510// ModelName returns the name of the model the agent is using.
511func (a *Agent) ModelName() string {
512 return a.config.Model
513}
514
Philip Zeyliger43a0bfc2025-07-14 14:54:27 -0700515// GetConvo returns the conversation interface for debugging purposes.
516func (a *Agent) GetConvo() ConvoInterface {
517 return a.convo
518}
519
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700520// NewIterator implements CodingAgent.
521func (a *Agent) NewIterator(ctx context.Context, nextMessageIdx int) MessageIterator {
522 a.mu.Lock()
523 defer a.mu.Unlock()
524
525 return &MessageIteratorImpl{
526 agent: a,
527 ctx: ctx,
528 nextMessageIdx: nextMessageIdx,
529 ch: make(chan *AgentMessage, 100),
530 }
531}
532
533type MessageIteratorImpl struct {
534 agent *Agent
535 ctx context.Context
536 nextMessageIdx int
537 ch chan *AgentMessage
538 subscribed bool
539}
540
541func (m *MessageIteratorImpl) Close() {
542 m.agent.mu.Lock()
543 defer m.agent.mu.Unlock()
544 // Delete ourselves from the subscribers list
545 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
546 return x == m.ch
547 })
548 close(m.ch)
549}
550
551func (m *MessageIteratorImpl) Next() *AgentMessage {
552 // We avoid subscription at creation to let ourselves catch up to "current state"
553 // before subscribing.
554 if !m.subscribed {
555 m.agent.mu.Lock()
556 if m.nextMessageIdx < len(m.agent.history) {
557 msg := &m.agent.history[m.nextMessageIdx]
558 m.nextMessageIdx++
559 m.agent.mu.Unlock()
560 return msg
561 }
562 // The next message doesn't exist yet, so let's subscribe
563 m.agent.subscribers = append(m.agent.subscribers, m.ch)
564 m.subscribed = true
565 m.agent.mu.Unlock()
566 }
567
568 for {
569 select {
570 case <-m.ctx.Done():
571 m.agent.mu.Lock()
572 // Delete ourselves from the subscribers list
573 m.agent.subscribers = slices.DeleteFunc(m.agent.subscribers, func(x chan *AgentMessage) bool {
574 return x == m.ch
575 })
576 m.subscribed = false
577 m.agent.mu.Unlock()
578 return nil
579 case msg, ok := <-m.ch:
580 if !ok {
581 // Close may have been called
582 return nil
583 }
584 if msg.Idx == m.nextMessageIdx {
585 m.nextMessageIdx++
586 return msg
587 }
588 slog.Debug("Out of order messages", "expected", m.nextMessageIdx, "got", msg.Idx, "m", msg.Content)
589 panic("out of order message")
590 }
591 }
592}
593
Sean McCulloughd9d45812025-04-30 16:53:41 -0700594// Assert that Agent satisfies the CodingAgent interface.
595var _ CodingAgent = &Agent{}
596
597// StateName implements CodingAgent.
598func (a *Agent) CurrentStateName() string {
599 if a.stateMachine == nil {
600 return ""
601 }
Josh Bleecher Snydered17fdf2025-05-23 17:26:07 +0000602 return a.stateMachine.CurrentState().String()
Sean McCulloughd9d45812025-04-30 16:53:41 -0700603}
604
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -0700605// CurrentTodoContent returns the current todo list data as JSON.
606// It returns an empty string if no todos exist.
607func (a *Agent) CurrentTodoContent() string {
608 todoPath := claudetool.TodoFilePath(a.config.SessionID)
609 content, err := os.ReadFile(todoPath)
610 if err != nil {
611 return ""
612 }
613 return string(content)
614}
615
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700616// generateConversationSummary asks the LLM to create a comprehensive summary of the current conversation
617func (a *Agent) generateConversationSummary(ctx context.Context) (string, error) {
618 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.
619
620IMPORTANT: 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.
621
622Please create a detailed summary that includes:
623
6241. **User's Request**: What did the user originally ask me to do? What was their goal?
625
6262. **Work Completed**: What have we accomplished together? Include any code changes, files created/modified, problems solved, etc.
627
6283. **Key Technical Decisions**: What important technical choices were made during our work and why?
629
6304. **Current State**: What is the current state of the project? What files, tools, or systems are we working with?
631
6325. **Next Steps**: What still needs to be done to complete the user's request?
633
6346. **Important Context**: Any crucial information about the user's codebase, environment, constraints, or specific preferences they mentioned.
635
636Focus 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.
637
638Reply with ONLY the summary content - no meta-commentary about creating the summary.`
639
640 userMessage := llm.UserStringMessage(msg)
641 // Use a subconversation with history to get the summary
642 // TODO: We don't have any tools here, so we should have enough tokens
643 // to capture a summary, but we may need to modify the history (e.g., remove
644 // TODO data) to save on some tokens.
645 convo := a.convo.SubConvoWithHistory()
646
647 // Modify the system prompt to provide context about the original task
648 originalSystemPrompt := convo.SystemPrompt
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000649 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 -0700650
651Your 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.
652
Josh Bleecher Snyder068f4bb2025-06-05 19:12:22 +0000653Original context: You are working in a coding environment with full access to development tools.`
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700654
655 resp, err := convo.SendMessage(userMessage)
656 if err != nil {
657 a.pushToOutbox(ctx, errorMessage(err))
658 return "", err
659 }
660 textContent := collectTextContent(resp)
661
662 // Restore original system prompt (though this subconvo will be discarded)
663 convo.SystemPrompt = originalSystemPrompt
664
665 return textContent, nil
666}
667
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000668// dumpMessageHistoryToTmp dumps the agent's entire message history to /tmp as JSON
669// and returns the filename
670func (a *Agent) dumpMessageHistoryToTmp(ctx context.Context) (string, error) {
671 // Create a filename based on session ID and timestamp
672 timestamp := time.Now().Format("20060102-150405")
673 filename := fmt.Sprintf("/tmp/sketch-messages-%s-%s.json", a.config.SessionID, timestamp)
674
675 // Marshal the entire message history to JSON
676 jsonData, err := json.MarshalIndent(a.history, "", " ")
677 if err != nil {
678 return "", fmt.Errorf("failed to marshal message history: %w", err)
679 }
680
681 // Write to file
Autoformatter3ad8c8d2025-07-15 21:05:23 +0000682 if err := os.WriteFile(filename, jsonData, 0o644); err != nil {
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000683 return "", fmt.Errorf("failed to write message history to %s: %w", filename, err)
684 }
685
686 slog.InfoContext(ctx, "Dumped message history to file", "filename", filename, "message_count", len(a.history))
687 return filename, nil
688}
689
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700690// CompactConversation compacts the current conversation by generating a summary
691// and restarting the conversation with that summary as the initial context
692func (a *Agent) CompactConversation(ctx context.Context) error {
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000693 // Dump the entire message history to /tmp as JSON before compacting
694 dumpFile, err := a.dumpMessageHistoryToTmp(ctx)
695 if err != nil {
696 slog.WarnContext(ctx, "Failed to dump message history to /tmp", "error", err)
697 // Continue with compaction even if dump fails
698 }
699
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700700 summary, err := a.generateConversationSummary(ctx)
701 if err != nil {
702 return fmt.Errorf("failed to generate conversation summary: %w", err)
703 }
704
705 a.mu.Lock()
706
707 // Get usage information before resetting conversation
708 lastUsage := a.convo.LastUsage()
709 contextWindow := a.config.Service.TokenContextWindow()
710 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
711
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000712 // Preserve cumulative usage across compaction
713 cumulativeUsage := a.convo.CumulativeUsage()
714
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700715 // Reset conversation state but keep all other state (git, working dir, etc.)
716 a.firstMessageIndex = len(a.history)
philip.zeyliger882e7ea2025-06-20 14:31:16 +0000717 a.convo = a.initConvoWithUsage(&cumulativeUsage)
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700718
719 a.mu.Unlock()
720
721 // Create informative compaction message with token details
722 compactionMsg := fmt.Sprintf("📜 Conversation compacted to manage token limits. Previous context preserved in summary below.\n\n"+
723 "**Token Usage:** %d / %d tokens (%.1f%% of context window)",
724 currentContextSize, contextWindow, float64(currentContextSize)/float64(contextWindow)*100)
725
726 a.pushToOutbox(ctx, AgentMessage{
727 Type: CompactMessageType,
728 Content: compactionMsg,
729 })
730
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000731 // Create the message content with dump file information if available
732 var messageContent string
733 if dumpFile != "" {
734 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)
735 } else {
736 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)
737 }
738
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700739 a.pushToOutbox(ctx, AgentMessage{
740 Type: UserMessageType,
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000741 Content: messageContent,
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700742 })
Philip Zeyliger9022ae02025-07-14 20:52:30 +0000743 a.inbox <- messageContent
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700744
745 return nil
746}
747
Earl Lee2e463fb2025-04-17 11:22:22 -0700748func (a *Agent) URL() string { return a.url }
749
Philip Zeyliger5f26a342025-07-04 01:30:29 +0000750// GetPorts returns the cached list of open TCP ports.
751func (a *Agent) GetPorts() []portlist.Port {
752 if a.portMonitor == nil {
753 return nil
754 }
755 return a.portMonitor.GetPorts()
756}
757
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000758// BranchName returns the git branch name for the conversation.
759func (a *Agent) BranchName() string {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700760 return a.gitState.BranchName(a.config.BranchPrefix)
761}
762
763// Slug returns the slug identifier for this conversation.
764func (a *Agent) Slug() string {
765 return a.gitState.Slug()
766}
767
768// IncrementRetryNumber increments the retry number for branch naming conflicts
769func (a *Agent) IncrementRetryNumber() {
770 a.gitState.IncrementRetryNumber()
Josh Bleecher Snyder47b19362025-04-30 01:34:14 +0000771}
772
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000773// OutstandingLLMCallCount returns the number of outstanding LLM calls.
774func (a *Agent) OutstandingLLMCallCount() int {
775 a.mu.Lock()
776 defer a.mu.Unlock()
777 return len(a.outstandingLLMCalls)
778}
779
780// OutstandingToolCalls returns the names of outstanding tool calls.
781func (a *Agent) OutstandingToolCalls() []string {
782 a.mu.Lock()
783 defer a.mu.Unlock()
784
785 tools := make([]string, 0, len(a.outstandingToolCalls))
786 for _, toolName := range a.outstandingToolCalls {
787 tools = append(tools, toolName)
788 }
789 return tools
790}
791
Earl Lee2e463fb2025-04-17 11:22:22 -0700792// OS returns the operating system of the client.
793func (a *Agent) OS() string {
794 return a.config.ClientGOOS
795}
796
Philip Zeyligerc72fff52025-04-29 20:17:54 +0000797func (a *Agent) SessionID() string {
798 return a.config.SessionID
799}
800
philip.zeyliger8773e682025-06-11 21:36:21 -0700801// SSHConnectionString returns the SSH connection string for the container.
802func (a *Agent) SSHConnectionString() string {
803 return a.config.SSHConnectionString
804}
805
Philip Zeyliger18532b22025-04-23 21:11:46 +0000806// OutsideOS returns the operating system of the outside system.
807func (a *Agent) OutsideOS() string {
808 return a.outsideOS
Philip Zeyligerd1402952025-04-23 03:54:37 +0000809}
810
Philip Zeyliger18532b22025-04-23 21:11:46 +0000811// OutsideHostname returns the hostname of the outside system.
812func (a *Agent) OutsideHostname() string {
813 return a.outsideHostname
Philip Zeyligerd1402952025-04-23 03:54:37 +0000814}
815
Philip Zeyliger18532b22025-04-23 21:11:46 +0000816// OutsideWorkingDir returns the working directory on the outside system.
817func (a *Agent) OutsideWorkingDir() string {
818 return a.outsideWorkingDir
Philip Zeyligerd1402952025-04-23 03:54:37 +0000819}
820
821// GitOrigin returns the URL of the git remote 'origin' if it exists.
822func (a *Agent) GitOrigin() string {
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +0000823 return a.config.OriginalGitOrigin
Philip Zeyligerd1402952025-04-23 03:54:37 +0000824}
825
Philip Zeyliger254c49f2025-07-17 17:26:24 -0700826// PassthroughUpstream returns whether passthrough upstream is enabled.
827func (a *Agent) PassthroughUpstream() bool {
828 return a.config.PassthroughUpstream
829}
830
bankseancad67b02025-06-27 21:57:05 +0000831// GitUsername returns the git user name from the agent config.
832func (a *Agent) GitUsername() string {
833 return a.config.GitUsername
834}
835
Philip Zeyliger64f60462025-06-16 13:57:10 -0700836// DiffStats returns the number of lines added and removed from sketch-base to HEAD
837func (a *Agent) DiffStats() (int, int) {
838 return a.gitState.DiffStats()
839}
840
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000841func (a *Agent) OpenBrowser(url string) {
842 if !a.IsInContainer() {
843 browser.Open(url)
844 return
845 }
846 // We're in Docker, need to send a request to the Git server
847 // to signal that the outer process should open the browser.
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700848 // We don't get to specify a URL, because we are untrusted.
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000849 httpc := &http.Client{Timeout: 5 * time.Second}
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700850 resp, err := httpc.Post(a.outsideHTTP+"/browser", "text/plain", nil)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000851 if err != nil {
Josh Bleecher Snyder99570462025-05-05 10:26:14 -0700852 slog.Debug("browser launch request connection failed", "err", err)
Josh Bleecher Snyder3e2111b2025-04-30 17:53:28 +0000853 return
854 }
855 defer resp.Body.Close()
856 if resp.StatusCode == http.StatusOK {
857 return
858 }
859 body, _ := io.ReadAll(resp.Body)
860 slog.Debug("browser launch request execution failed", "status", resp.Status, "body", string(body))
861}
862
Sean McCullough96b60dd2025-04-30 09:49:10 -0700863// CurrentState returns the current state of the agent's state machine.
864func (a *Agent) CurrentState() State {
865 return a.stateMachine.CurrentState()
866}
867
Philip Zeyliger2c4db092025-04-28 16:57:50 -0700868func (a *Agent) IsInContainer() bool {
869 return a.config.InDocker
870}
871
872func (a *Agent) FirstMessageIndex() int {
873 a.mu.Lock()
874 defer a.mu.Unlock()
875 return a.firstMessageIndex
876}
877
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700878// SetSlug sets a human-readable identifier for the conversation.
879func (a *Agent) SetSlug(slug string) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700880 a.mu.Lock()
881 defer a.mu.Unlock()
Philip Zeyligerb7c58752025-05-01 10:10:17 -0700882
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700883 a.gitState.SetSlug(slug)
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000884 convo, ok := a.convo.(*conversation.Convo)
885 if ok {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -0700886 convo.ExtraData["branch"] = a.BranchName()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +0000887 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700888}
889
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000890// OnToolCall implements ant.Listener and tracks the start of a tool call.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700891func (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 +0000892 // Track the tool call
893 a.mu.Lock()
894 a.outstandingToolCalls[id] = toolName
895 a.mu.Unlock()
896}
897
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700898// contentToString converts []llm.Content to a string, concatenating all text content and skipping non-text types.
899// If there's only one element in the array and it's a text type, it returns that text directly.
900// It also processes nested ToolResult arrays recursively.
901func contentToString(contents []llm.Content) string {
902 if len(contents) == 0 {
903 return ""
904 }
905
906 // If there's only one element and it's a text type, return it directly
907 if len(contents) == 1 && contents[0].Type == llm.ContentTypeText {
908 return contents[0].Text
909 }
910
911 // Otherwise, concatenate all text content
912 var result strings.Builder
913 for _, content := range contents {
914 if content.Type == llm.ContentTypeText {
915 result.WriteString(content.Text)
916 } else if content.Type == llm.ContentTypeToolResult && len(content.ToolResult) > 0 {
917 // Recursively process nested tool results
918 result.WriteString(contentToString(content.ToolResult))
919 }
920 }
921
922 return result.String()
923}
924
Earl Lee2e463fb2025-04-17 11:22:22 -0700925// OnToolResult implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700926func (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 +0000927 // Remove the tool call from outstanding calls
928 a.mu.Lock()
929 delete(a.outstandingToolCalls, toolID)
930 a.mu.Unlock()
931
Earl Lee2e463fb2025-04-17 11:22:22 -0700932 m := AgentMessage{
933 Type: ToolUseMessageType,
934 Content: content.Text,
Philip Zeyliger72252cb2025-05-10 17:00:08 -0700935 ToolResult: contentToString(content.ToolResult),
Earl Lee2e463fb2025-04-17 11:22:22 -0700936 ToolError: content.ToolError,
937 ToolName: toolName,
938 ToolInput: string(toolInput),
939 ToolCallId: content.ToolUseID,
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700940 StartTime: content.ToolUseStartTime,
941 EndTime: content.ToolUseEndTime,
Josh Bleecher Snyder3dd3e412025-07-22 20:32:03 -0700942 Display: content.Display,
Earl Lee2e463fb2025-04-17 11:22:22 -0700943 }
944
945 // Calculate the elapsed time if both start and end times are set
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700946 if content.ToolUseStartTime != nil && content.ToolUseEndTime != nil {
947 elapsed := content.ToolUseEndTime.Sub(*content.ToolUseStartTime)
Earl Lee2e463fb2025-04-17 11:22:22 -0700948 m.Elapsed = &elapsed
949 }
950
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700951 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -0700952 a.pushToOutbox(ctx, m)
953}
954
955// OnRequest implements ant.Listener.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700956func (a *Agent) OnRequest(ctx context.Context, convo *conversation.Convo, id string, msg *llm.Message) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000957 a.mu.Lock()
958 defer a.mu.Unlock()
959 a.outstandingLLMCalls[id] = struct{}{}
Earl Lee2e463fb2025-04-17 11:22:22 -0700960 // We already get tool results from the above. We send user messages to the outbox in the agent loop.
961}
962
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700963// OnResponse implements conversation.Listener. Responses contain messages from the LLM
Earl Lee2e463fb2025-04-17 11:22:22 -0700964// that need to be displayed (as well as tool calls that we send along when
965// they're done). (It would be reasonable to also mention tool calls when they're
966// started, but we don't do that yet.)
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -0700967func (a *Agent) OnResponse(ctx context.Context, convo *conversation.Convo, id string, resp *llm.Response) {
Philip Zeyliger99a9a022025-04-27 15:15:25 +0000968 // Remove the LLM call from outstanding calls
969 a.mu.Lock()
970 delete(a.outstandingLLMCalls, id)
971 a.mu.Unlock()
972
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -0700973 if resp == nil {
974 // LLM API call failed
975 m := AgentMessage{
976 Type: ErrorMessageType,
977 Content: "API call failed, type 'continue' to try again",
978 }
979 m.SetConvo(convo)
980 a.pushToOutbox(ctx, m)
981 return
982 }
983
Earl Lee2e463fb2025-04-17 11:22:22 -0700984 endOfTurn := false
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700985 if convo.Parent == nil { // subconvos never end the turn
986 switch resp.StopReason {
987 case llm.StopReasonToolUse:
988 // Check whether any of the tool calls are for tools that should end the turn
989 ToolSearch:
990 for _, part := range resp.Content {
991 if part.Type != llm.ContentTypeToolUse {
992 continue
993 }
Sean McCullough021557a2025-05-05 23:20:53 +0000994 // Find the tool by name
995 for _, tool := range convo.Tools {
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -0700996 if tool.Name == part.ToolName {
997 endOfTurn = tool.EndsTurn
998 break ToolSearch
Sean McCullough021557a2025-05-05 23:20:53 +0000999 }
1000 }
Sean McCullough021557a2025-05-05 23:20:53 +00001001 }
Josh Bleecher Snyder4fcde4a2025-05-05 18:28:13 -07001002 default:
1003 endOfTurn = true
Sean McCullough021557a2025-05-05 23:20:53 +00001004 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001005 }
1006 m := AgentMessage{
1007 Type: AgentMessageType,
1008 Content: collectTextContent(resp),
1009 EndOfTurn: endOfTurn,
1010 Usage: &resp.Usage,
1011 StartTime: resp.StartTime,
1012 EndTime: resp.EndTime,
1013 }
1014
1015 // Extract any tool calls from the response
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001016 if resp.StopReason == llm.StopReasonToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -07001017 var toolCalls []ToolCall
1018 for _, part := range resp.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001019 if part.Type == llm.ContentTypeToolUse {
Earl Lee2e463fb2025-04-17 11:22:22 -07001020 toolCalls = append(toolCalls, ToolCall{
1021 Name: part.ToolName,
1022 Input: string(part.ToolInput),
1023 ToolCallId: part.ID,
1024 })
1025 }
1026 }
1027 m.ToolCalls = toolCalls
1028 }
1029
1030 // Calculate the elapsed time if both start and end times are set
1031 if resp.StartTime != nil && resp.EndTime != nil {
1032 elapsed := resp.EndTime.Sub(*resp.StartTime)
1033 m.Elapsed = &elapsed
1034 }
1035
Josh Bleecher Snyder50a1d622025-04-29 09:59:03 -07001036 m.SetConvo(convo)
Earl Lee2e463fb2025-04-17 11:22:22 -07001037 a.pushToOutbox(ctx, m)
1038}
1039
1040// WorkingDir implements CodingAgent.
1041func (a *Agent) WorkingDir() string {
1042 return a.workingDir
1043}
1044
Josh Bleecher Snyderc5848f32025-05-28 18:50:58 +00001045// RepoRoot returns the git repository root directory.
1046func (a *Agent) RepoRoot() string {
1047 return a.repoRoot
1048}
1049
Earl Lee2e463fb2025-04-17 11:22:22 -07001050// MessageCount implements CodingAgent.
1051func (a *Agent) MessageCount() int {
1052 a.mu.Lock()
1053 defer a.mu.Unlock()
1054 return len(a.history)
1055}
1056
1057// Messages implements CodingAgent.
1058func (a *Agent) Messages(start int, end int) []AgentMessage {
1059 a.mu.Lock()
1060 defer a.mu.Unlock()
1061 return slices.Clone(a.history[start:end])
1062}
1063
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001064// ShouldCompact checks if the conversation should be compacted based on token usage
1065func (a *Agent) ShouldCompact() bool {
1066 // Get the threshold from environment variable, default to 0.94 (94%)
1067 // (Because default Claude output is 8192 tokens, which is 4% of 200,000 tokens,
1068 // and a little bit of buffer.)
1069 thresholdRatio := 0.94
1070 if envThreshold := os.Getenv("SKETCH_COMPACT_THRESHOLD_RATIO"); envThreshold != "" {
1071 if parsed, err := strconv.ParseFloat(envThreshold, 64); err == nil && parsed > 0 && parsed <= 1.0 {
1072 thresholdRatio = parsed
1073 }
1074 }
1075
1076 // Get the most recent usage to check current context size
1077 lastUsage := a.convo.LastUsage()
1078
1079 if lastUsage.InputTokens == 0 {
1080 // No API calls made yet
1081 return false
1082 }
1083
1084 // Calculate the current context size from the last API call
1085 // This includes all tokens that were part of the input context:
1086 // - Input tokens (user messages, system prompt, conversation history)
1087 // - Cache read tokens (cached parts of the context)
1088 // - Cache creation tokens (new parts being cached)
1089 currentContextSize := lastUsage.InputTokens + lastUsage.CacheReadInputTokens + lastUsage.CacheCreationInputTokens
1090
1091 // Get the service's token context window
1092 service := a.config.Service
1093 contextWindow := service.TokenContextWindow()
1094
1095 // Calculate threshold
1096 threshold := uint64(float64(contextWindow) * thresholdRatio)
1097
1098 // Check if we've exceeded the threshold
1099 return currentContextSize >= threshold
1100}
1101
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001102func (a *Agent) OriginalBudget() conversation.Budget {
Earl Lee2e463fb2025-04-17 11:22:22 -07001103 return a.originalBudget
1104}
1105
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001106// Upstream returns the upstream branch for git work
1107func (a *Agent) Upstream() string {
1108 return a.gitState.Upstream()
1109}
1110
Earl Lee2e463fb2025-04-17 11:22:22 -07001111// AgentConfig contains configuration for creating a new Agent.
1112type AgentConfig struct {
Josh Bleecher Snyderb421a242025-05-29 23:22:55 +00001113 Context context.Context
1114 Service llm.Service
1115 Budget conversation.Budget
1116 GitUsername string
1117 GitEmail string
1118 SessionID string
1119 ClientGOOS string
1120 ClientGOARCH string
1121 InDocker bool
1122 OneShot bool
1123 WorkingDir string
Josh Bleecher Snyder4571fd62025-07-25 16:56:02 +00001124 // Model is the name of the LLM model being used
1125 Model string
Philip Zeyliger18532b22025-04-23 21:11:46 +00001126 // Outside information
1127 OutsideHostname string
1128 OutsideOS string
1129 OutsideWorkingDir string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001130
1131 // Outtie's HTTP to, e.g., open a browser
1132 OutsideHTTP string
1133 // Outtie's Git server
1134 GitRemoteAddr string
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001135 // Original git origin URL from host repository, if any
1136 OriginalGitOrigin string
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001137 // Upstream branch for git work
1138 Upstream string
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001139 // Commit to checkout from Outtie
1140 Commit string
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001141 // Prefix for git branches created by sketch
1142 BranchPrefix string
philip.zeyliger6d3de482025-06-10 19:38:14 -07001143 // LinkToGitHub enables GitHub branch linking in UI
1144 LinkToGitHub bool
philip.zeyliger8773e682025-06-11 21:36:21 -07001145 // SSH connection string for connecting to the container
1146 SSHConnectionString string
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001147 // Skaband client for session history (optional)
1148 SkabandClient *skabandclient.SkabandClient
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001149 // MCP server configurations
1150 MCPServers []string
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001151 // Timeout configuration for bash tool
1152 BashTimeouts *claudetool.Timeouts
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001153 // PassthroughUpstream configures upstream remote for passthrough to innie
1154 PassthroughUpstream bool
Josh Bleecher Snyder1e551672025-07-30 03:16:54 +00001155 // FetchOnLaunch enables git fetch during initialization
1156 FetchOnLaunch bool
Earl Lee2e463fb2025-04-17 11:22:22 -07001157}
1158
1159// NewAgent creates a new Agent.
1160// It is not usable until Init() is called.
1161func NewAgent(config AgentConfig) *Agent {
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001162 // Set default branch prefix if not specified
1163 if config.BranchPrefix == "" {
1164 config.BranchPrefix = "sketch/"
1165 }
1166
Earl Lee2e463fb2025-04-17 11:22:22 -07001167 agent := &Agent{
Philip Zeyligerf2872992025-05-22 10:35:28 -07001168 config: config,
1169 ready: make(chan struct{}),
1170 inbox: make(chan string, 100),
1171 subscribers: make([]chan *AgentMessage, 0),
1172 startedAt: time.Now(),
1173 originalBudget: config.Budget,
1174 gitState: AgentGitState{
1175 seenCommits: make(map[string]bool),
1176 gitRemoteAddr: config.GitRemoteAddr,
Josh Bleecher Snyder664404e2025-06-04 21:56:42 +00001177 upstream: config.Upstream,
Philip Zeyligerf2872992025-05-22 10:35:28 -07001178 },
Philip Zeyliger99a9a022025-04-27 15:15:25 +00001179 outsideHostname: config.OutsideHostname,
1180 outsideOS: config.OutsideOS,
1181 outsideWorkingDir: config.OutsideWorkingDir,
1182 outstandingLLMCalls: make(map[string]struct{}),
1183 outstandingToolCalls: make(map[string]string),
Sean McCullough96b60dd2025-04-30 09:49:10 -07001184 stateMachine: NewStateMachine(),
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001185 workingDir: config.WorkingDir,
1186 outsideHTTP: config.OutsideHTTP,
Philip Zeyligerda623b52025-07-04 01:12:38 +00001187
1188 mcpManager: mcp.NewMCPManager(),
Earl Lee2e463fb2025-04-17 11:22:22 -07001189 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001190
1191 // Initialize port monitor with 5-second interval
1192 agent.portMonitor = NewPortMonitor(agent, 5*time.Second)
1193
Earl Lee2e463fb2025-04-17 11:22:22 -07001194 return agent
1195}
1196
1197type AgentInit struct {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001198 NoGit bool // only for testing
Earl Lee2e463fb2025-04-17 11:22:22 -07001199
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001200 InDocker bool
1201 HostAddr string
Earl Lee2e463fb2025-04-17 11:22:22 -07001202}
1203
1204func (a *Agent) Init(ini AgentInit) error {
Josh Bleecher Snyder9c07e1d2025-04-28 19:25:37 -07001205 if a.convo != nil {
1206 return fmt.Errorf("Agent.Init: already initialized")
1207 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001208 ctx := a.config.Context
Philip Zeyliger716bfee2025-05-21 18:32:31 -07001209 slog.InfoContext(ctx, "agent initializing")
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001210
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001211 // If a remote + commit was specified, clone it.
1212 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001213 if _, err := os.Stat("/app/.git"); err != nil {
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00001214 slog.InfoContext(ctx, "cloning git repo", "commit", a.config.Commit)
1215 // TODO: --reference-if-able instead?
1216 cmd := exec.CommandContext(ctx, "git", "clone", "--reference", "/git-ref", a.gitState.gitRemoteAddr, "/app")
1217 if out, err := cmd.CombinedOutput(); err != nil {
1218 return fmt.Errorf("failed to clone repository from %s: %s: %w", a.gitState.gitRemoteAddr, out, err)
1219 }
Josh Bleecher Snyder784d5bd2025-07-11 00:09:30 +00001220 }
1221 }
1222
1223 if a.workingDir != "" {
1224 err := os.Chdir(a.workingDir)
1225 if err != nil {
1226 return fmt.Errorf("failed to change working directory to %s: %w", a.workingDir, err)
1227 }
1228 }
1229
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001230 if !ini.NoGit {
Philip Zeyligeraccf37c2025-07-18 07:29:19 -07001231 if a.gitState.gitRemoteAddr != "" {
1232 if err := upsertRemoteOrigin(ctx, "/app", a.gitState.gitRemoteAddr); err != nil {
1233 return err
1234 }
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001235 }
Philip Zeyligere1c8b7b2025-07-03 14:50:26 -07001236
1237 // Configure git user settings
1238 if a.config.GitEmail != "" {
1239 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.email", a.config.GitEmail)
1240 cmd.Dir = a.workingDir
1241 if out, err := cmd.CombinedOutput(); err != nil {
1242 return fmt.Errorf("git config --global user.email: %s: %v", out, err)
1243 }
1244 }
1245 if a.config.GitUsername != "" {
1246 cmd := exec.CommandContext(ctx, "git", "config", "--global", "user.name", a.config.GitUsername)
1247 cmd.Dir = a.workingDir
1248 if out, err := cmd.CombinedOutput(); err != nil {
1249 return fmt.Errorf("git config --global user.name: %s: %v", out, err)
1250 }
1251 }
1252 // Configure git http.postBuffer
1253 cmd := exec.CommandContext(ctx, "git", "config", "--global", "http.postBuffer", "524288000")
1254 cmd.Dir = a.workingDir
1255 if out, err := cmd.CombinedOutput(); err != nil {
1256 return fmt.Errorf("git config --global http.postBuffer: %s: %v", out, err)
1257 }
Philip Zeyliger254c49f2025-07-17 17:26:24 -07001258
1259 // Configure passthrough upstream if enabled
1260 if a.config.PassthroughUpstream {
1261 if err := a.configurePassthroughUpstream(ctx); err != nil {
1262 return fmt.Errorf("failed to configure passthrough upstream: %w", err)
1263 }
1264 }
Philip Zeyliger2f0eb692025-06-04 09:53:42 -07001265 }
1266
Philip Zeyligerf2872992025-05-22 10:35:28 -07001267 // If a commit was specified, we fetch and reset to it.
1268 if a.config.Commit != "" && a.gitState.gitRemoteAddr != "" {
Josh Bleecher Snyder1e551672025-07-30 03:16:54 +00001269 if a.config.FetchOnLaunch {
1270 slog.InfoContext(ctx, "updating git repo", "commit", a.config.Commit)
1271 cmd := exec.CommandContext(ctx, "git", "fetch", "--prune", "origin")
1272 cmd.Dir = a.workingDir
1273 if out, err := cmd.CombinedOutput(); err != nil {
1274 return fmt.Errorf("git fetch: %s: %w", out, err)
1275 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001276 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001277 // The -B resets the branch if it already exists (or creates it if it doesn't)
Josh Bleecher Snyder1e551672025-07-30 03:16:54 +00001278 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001279 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001280 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1281 // Remove git hooks if they exist and retry
1282 // Only try removing hooks if we haven't already removed them during fetch
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001283 hookPath := filepath.Join(a.workingDir, ".git", "hooks")
Pokey Rule7a113622025-05-12 10:58:45 +01001284 if _, statErr := os.Stat(hookPath); statErr == nil {
1285 slog.WarnContext(ctx, "git checkout failed, removing hooks and retrying",
1286 slog.String("error", err.Error()),
1287 slog.String("output", string(checkoutOut)))
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001288 if removeErr := removeGitHooks(ctx, a.workingDir); removeErr != nil {
Pokey Rule7a113622025-05-12 10:58:45 +01001289 slog.WarnContext(ctx, "failed to remove git hooks", slog.String("error", removeErr.Error()))
1290 }
1291
1292 // Retry the checkout operation
Philip Zeyliger1417b692025-06-12 11:07:04 -07001293 cmd = exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip", a.config.Commit)
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001294 cmd.Dir = a.workingDir
Pokey Rule7a113622025-05-12 10:58:45 +01001295 if retryOut, retryErr := cmd.CombinedOutput(); retryErr != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001296 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 +01001297 }
1298 } else {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001299 return fmt.Errorf("git checkout -f -B sketch-wip %s: %s: %w", a.config.Commit, checkoutOut, err)
Pokey Rule7a113622025-05-12 10:58:45 +01001300 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001301 }
Philip Zeyliger4c1cea82025-06-09 14:16:52 -07001302 } else if a.IsInContainer() {
1303 // If we're not running in a container, we don't switch branches (nor push branches back and forth).
1304 slog.InfoContext(ctx, "checking out branch", slog.String("commit", a.config.Commit))
1305 cmd := exec.CommandContext(ctx, "git", "checkout", "-f", "-B", "sketch-wip")
1306 cmd.Dir = a.workingDir
1307 if checkoutOut, err := cmd.CombinedOutput(); err != nil {
1308 return fmt.Errorf("git checkout -f -B sketch-wip: %s: %w", checkoutOut, err)
1309 }
1310 } else {
1311 slog.InfoContext(ctx, "Not checking out any branch")
Earl Lee2e463fb2025-04-17 11:22:22 -07001312 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001313
1314 if ini.HostAddr != "" {
1315 a.url = "http://" + ini.HostAddr
1316 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001317
1318 if !ini.NoGit {
1319 repoRoot, err := repoRoot(ctx, a.workingDir)
1320 if err != nil {
1321 return fmt.Errorf("repoRoot: %w", err)
1322 }
1323 a.repoRoot = repoRoot
1324
Josh Bleecher Snyderfea9e272025-06-02 21:21:59 +00001325 if a.IsInContainer() {
Philip Zeyligerf75ba2c2025-06-02 17:02:51 -07001326 if err := setupGitHooks(a.repoRoot); err != nil {
1327 slog.WarnContext(ctx, "failed to set up git hooks", "err", err)
1328 }
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07001329 }
1330
philz24613202025-07-15 20:56:21 -07001331 // Check if we have any commits, and if not, create an empty initial commit
1332 cmd := exec.CommandContext(ctx, "git", "rev-list", "--all", "--count")
1333 cmd.Dir = repoRoot
1334 countOut, err := cmd.CombinedOutput()
1335 if err != nil {
1336 return fmt.Errorf("git rev-list --all --count: %s: %w", countOut, err)
1337 }
1338 commitCount := strings.TrimSpace(string(countOut))
1339 if commitCount == "0" {
1340 slog.Info("No commits found, creating empty initial commit")
1341 cmd = exec.CommandContext(ctx, "git", "commit", "--allow-empty", "-m", "Initial empty commit")
1342 cmd.Dir = repoRoot
1343 if commitOut, err := cmd.CombinedOutput(); err != nil {
1344 return fmt.Errorf("git commit --allow-empty: %s: %w", commitOut, err)
1345 }
1346 }
1347
1348 cmd = exec.CommandContext(ctx, "git", "tag", "-f", a.SketchGitBaseRef(), "HEAD")
Philip Zeyliger49edc922025-05-14 09:45:45 -07001349 cmd.Dir = repoRoot
1350 if out, err := cmd.CombinedOutput(); err != nil {
1351 return fmt.Errorf("git tag -f %s %s: %s: %w", a.SketchGitBaseRef(), "HEAD", out, err)
1352 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001353
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001354 slog.Info("running codebase analysis")
1355 codebase, err := onstart.AnalyzeCodebase(ctx, a.repoRoot)
1356 if err != nil {
1357 slog.Warn("failed to analyze codebase", "error", err)
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001358 }
Josh Bleecher Snyder0e5b8c62025-05-14 20:58:20 +00001359 a.codebase = codebase
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00001360
Josh Bleecher Snyder9daa5182025-05-16 18:34:00 +00001361 codereview, err := codereview.NewCodeReviewer(ctx, a.repoRoot, a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07001362 if err != nil {
Josh Bleecher Snyderf4047bb2025-05-05 23:02:56 +00001363 return fmt.Errorf("Agent.Init: codereview.NewCodeReviewer: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07001364 }
1365 a.codereview = codereview
Philip Zeyligerd1402952025-04-23 03:54:37 +00001366
Earl Lee2e463fb2025-04-17 11:22:22 -07001367 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07001368 a.gitState.lastSketch = a.SketchGitBase()
Earl Lee2e463fb2025-04-17 11:22:22 -07001369 a.convo = a.initConvo()
1370 close(a.ready)
1371 return nil
1372}
1373
Josh Bleecher Snyderdbe02302025-04-29 16:44:23 -07001374//go:embed agent_system_prompt.txt
1375var agentSystemPrompt string
1376
Earl Lee2e463fb2025-04-17 11:22:22 -07001377// initConvo initializes the conversation.
1378// It must not be called until all agent fields are initialized,
1379// particularly workingDir and git.
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001380func (a *Agent) initConvo() *conversation.Convo {
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001381 return a.initConvoWithUsage(nil)
1382}
1383
1384// initConvoWithUsage initializes the conversation with optional preserved usage.
1385func (a *Agent) initConvoWithUsage(usage *conversation.CumulativeUsage) *conversation.Convo {
Earl Lee2e463fb2025-04-17 11:22:22 -07001386 ctx := a.config.Context
philip.zeyliger882e7ea2025-06-20 14:31:16 +00001387 convo := conversation.New(ctx, a.config.Service, usage)
Earl Lee2e463fb2025-04-17 11:22:22 -07001388 convo.PromptCaching = true
1389 convo.Budget = a.config.Budget
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00001390 convo.SystemPrompt = a.renderSystemPrompt()
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001391 convo.ExtraData = map[string]any{"session_id": a.config.SessionID}
Earl Lee2e463fb2025-04-17 11:22:22 -07001392
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001393 bashTool := &claudetool.BashTool{
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001394 EnableJITInstall: claudetool.EnableBashToolJITInstall,
1395 Timeouts: a.config.BashTimeouts,
Josh Bleecher Snyder6fe809c2025-07-24 16:22:51 -07001396 Pwd: a.workingDir,
Josh Bleecher Snyder17b2fd92025-07-09 22:47:13 +00001397 }
Josh Bleecher Snyder04f16a52025-07-30 11:46:25 -07001398 patchTool := &claudetool.PatchTool{
Josh Bleecher Snyder7f18fb62025-07-30 18:12:29 -07001399 Callback: a.patchCallback,
1400 Pwd: a.workingDir,
Josh Bleecher Snyder994e9842025-07-30 20:26:47 -07001401 Simplified: llm.UseSimplifiedPatch(a.config.Service),
Josh Bleecher Snyder7f18fb62025-07-30 18:12:29 -07001402 ClipboardEnabled: experiment.Enabled("clipboard"),
Josh Bleecher Snyder04f16a52025-07-30 11:46:25 -07001403 }
Josh Bleecher Snyderd499fd62025-04-30 01:31:29 +00001404
Earl Lee2e463fb2025-04-17 11:22:22 -07001405 // Register all tools with the conversation
1406 // When adding, removing, or modifying tools here, double-check that the termui tool display
1407 // template in termui/termui.go has pretty-printing support for all tools.
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001408
1409 var browserTools []*llm.Tool
Philip Zeyliger80b488d2025-05-10 18:21:54 -07001410 _, supportsScreenshots := a.config.Service.(*ant.Service)
1411 var bTools []*llm.Tool
1412 var browserCleanup func()
1413
1414 bTools, browserCleanup = browse.RegisterBrowserTools(a.config.Context, supportsScreenshots)
1415 // Add cleanup function to context cancel
1416 go func() {
1417 <-a.config.Context.Done()
1418 browserCleanup()
1419 }()
1420 browserTools = bTools
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001421
giofe6e7142025-06-18 08:51:23 +00001422 // TODO(gio): get these from the config
1423 dodoTools := dodo_tools.NewDodoTools(os.Getenv("DODO_API_BASE_ADDR"), os.Getenv("DODO_PROJECT_ID"))
1424
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001425 convo.Tools = []*llm.Tool{
Josh Bleecher Snyderd64bc912025-07-24 11:42:33 -07001426 bashTool.Tool(),
1427 claudetool.Keyword,
Josh Bleecher Snyder04f16a52025-07-30 11:46:25 -07001428 patchTool.Tool(),
Josh Bleecher Snyderd64bc912025-07-24 11:42:33 -07001429 claudetool.Think,
1430 claudetool.TodoRead,
1431 claudetool.TodoWrite,
1432 makeDoneTool(a.codereview),
1433 a.codereview.Tool(),
1434 claudetool.AboutSketch,
Josh Bleecher Snyder31785ae2025-05-06 01:50:58 +00001435 }
Philip Zeyliger33d282f2025-05-03 04:01:54 +00001436 convo.Tools = append(convo.Tools, browserTools...)
giofe6e7142025-06-18 08:51:23 +00001437 convo.Tools = append(convo.Tools, dodoTools...)
Philip Zeyligerc17ffe32025-06-05 19:49:13 -07001438
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001439 // Add MCP tools if configured
1440 if len(a.config.MCPServers) > 0 {
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001441
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001442 slog.InfoContext(ctx, "Initializing MCP connections", "servers", len(a.config.MCPServers))
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001443 serverConfigs, parseErrors := mcp.ParseServerConfigs(ctx, a.config.MCPServers)
1444
1445 // Replace any headers with value _sketch_public_key_ and _sketch_session_id_ with those values.
1446 for i := range serverConfigs {
1447 if serverConfigs[i].Headers != nil {
1448 for key, value := range serverConfigs[i].Headers {
Philip Zeyligerf2814ea2025-06-30 10:16:50 -07001449 // Replace env placeholders. E.g., "env:FOO" becomes os.Getenv("FOO")
1450 if strings.HasPrefix(value, "env:") {
1451 serverConfigs[i].Headers[key] = os.Getenv(value[4:])
Philip Zeyliger4201bde2025-06-27 17:22:43 -07001452 }
1453 }
1454 }
1455 }
Philip Zeyligerc540df72025-07-25 09:21:56 -07001456 mcpConnections, mcpErrors := a.mcpManager.ConnectToServerConfigs(ctx, serverConfigs, mcp.DefaultMCPConnectionTimeout, parseErrors)
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001457
1458 if len(mcpErrors) > 0 {
1459 for _, err := range mcpErrors {
1460 slog.ErrorContext(ctx, "MCP connection error", "error", err)
1461 // Send agent message about MCP connection failures
1462 a.pushToOutbox(ctx, AgentMessage{
1463 Type: ErrorMessageType,
1464 Content: fmt.Sprintf("MCP server connection failed: %v", err),
1465 })
1466 }
1467 }
1468
1469 if len(mcpConnections) > 0 {
1470 // Add tools from all successful connections
1471 totalTools := 0
1472 for _, connection := range mcpConnections {
1473 convo.Tools = append(convo.Tools, connection.Tools...)
1474 totalTools += len(connection.Tools)
1475 // Log tools per server using structured data
1476 slog.InfoContext(ctx, "Added MCP tools from server", "server", connection.ServerName, "count", len(connection.Tools), "tools", connection.ToolNames)
1477 }
1478 slog.InfoContext(ctx, "Total MCP tools added", "count", totalTools)
1479 } else {
1480 slog.InfoContext(ctx, "No MCP tools available after connection attempts")
1481 }
1482 }
1483
Earl Lee2e463fb2025-04-17 11:22:22 -07001484 convo.Listener = a
1485 return convo
1486}
1487
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001488// branchExists reports whether branchName exists, either locally or in well-known remotes.
1489func branchExists(dir, branchName string) bool {
1490 refs := []string{
1491 "refs/heads/",
1492 "refs/remotes/origin/",
Josh Bleecher Snyderfff269b2025-04-30 01:49:39 +00001493 }
1494 for _, ref := range refs {
1495 cmd := exec.Command("git", "show-ref", "--verify", "--quiet", ref+branchName)
1496 cmd.Dir = dir
1497 if cmd.Run() == nil { // exit code 0 means branch exists
1498 return true
1499 }
1500 }
1501 return false
1502}
1503
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001504func soleText(contents []llm.Content) (string, error) {
1505 if len(contents) != 1 {
1506 return "", fmt.Errorf("multiple contents %v", contents)
1507 }
1508 content := contents[0]
1509 if content.Type != llm.ContentTypeText || content.Text == "" {
1510 return "", fmt.Errorf("bad content %v", content)
1511 }
1512 return strings.TrimSpace(content.Text), nil
1513}
1514
1515// autoGenerateSlug automatically generates a slug based on the first user input
1516func (a *Agent) autoGenerateSlug(ctx context.Context, userContents []llm.Content) error {
1517 userText, err := soleText(userContents)
1518 if err != nil {
1519 return err
1520 }
1521 if userText == "" {
1522 return fmt.Errorf("set-slug: empty text content")
1523 }
1524
1525 // Create a subconversation without history for slug generation
1526 convo, ok := a.convo.(*conversation.Convo)
1527 if !ok {
1528 // In test environments, the conversation might be a mock interface
1529 // Skip slug generation in this case
1530 return fmt.Errorf("set-slug: can't make a subconvo (mock convo?)")
1531 }
1532
1533 // Loop until we find an acceptable slug
1534 var unavailableSlugs []string
1535 for {
1536 if len(unavailableSlugs) > 10 {
1537 // sanity check to prevent infinite loops
1538 return fmt.Errorf("set-slug: failed to construct a new slug after %d attempts", len(unavailableSlugs))
Earl Lee2e463fb2025-04-17 11:22:22 -07001539 }
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001540 subConvo := convo.SubConvo()
1541 subConvo.Hidden = true
1542
1543 // Prompt for slug generation
1544 prompt := `You are a slug generator for Sketch, an agentic coding environment.
1545The user's prompt will be in <user-prompt> tags. Any unavailable slugs will be listed in <unavailable-slug> tags.
1546Generate a 2-3 word alphanumeric hyphenated slug in imperative tense that captures the essence of their coding task.
1547Respond with only the slug.`
1548
1549 buf := new(strings.Builder)
1550 buf.WriteString("<slug-request>")
1551 if len(unavailableSlugs) > 0 {
1552 buf.WriteString("<unavailable-slugs>")
1553 }
1554 for _, x := range unavailableSlugs {
1555 buf.WriteString("<unavailable-slug>")
1556 buf.WriteString(x)
1557 buf.WriteString("</unavailable-slug>")
1558 }
1559 if len(unavailableSlugs) > 0 {
1560 buf.WriteString("</unavailable-slugs>")
1561 }
1562 buf.WriteString("<user-prompt>")
1563 buf.WriteString(userText)
1564 buf.WriteString("</user-prompt>")
1565 buf.WriteString("</slug-request>")
1566
1567 fullPrompt := prompt + "\n" + buf.String()
1568 userMessage := llm.UserStringMessage(fullPrompt)
1569
1570 resp, err := subConvo.SendMessage(userMessage)
1571 if err != nil {
1572 return fmt.Errorf("failed to generate slug: %w", err)
1573 }
1574
1575 // Extract the slug from the response
1576 slugText, err := soleText(resp.Content)
1577 if err != nil {
1578 return err
1579 }
1580 if slugText == "" {
1581 return fmt.Errorf("empty slug generated")
1582 }
1583
1584 // Clean and validate the slug
1585 slug := cleanSlugName(slugText)
1586 if slug == "" {
1587 return fmt.Errorf("slug could not be cleaned: %q", slugText)
1588 }
1589
1590 // Check if branch already exists using the same logic as the original set-slug tool
1591 a.SetSlug(slug) // Set slug first so BranchName() works correctly
1592 if branchExists(a.workingDir, a.BranchName()) {
1593 // try again
1594 unavailableSlugs = append(unavailableSlugs, slug)
1595 continue
1596 }
1597
1598 // Success! Slug is available and already set
1599 return nil
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001600 }
Josh Bleecher Snydera2a31502025-05-07 12:37:18 +00001601}
1602
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001603// patchCallback is the agent's patch tool callback.
1604// It warms the codereview cache in the background.
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001605func (a *Agent) patchCallback(input claudetool.PatchInput, output llm.ToolOut) llm.ToolOut {
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001606 if a.codereview != nil {
1607 a.codereview.WarmTestCache(input.Path)
1608 }
Josh Bleecher Snyder43b60b92025-07-21 14:57:10 -07001609 return output
Josh Bleecher Snyder6534c7a2025-07-01 01:48:52 +00001610}
1611
Earl Lee2e463fb2025-04-17 11:22:22 -07001612func (a *Agent) Ready() <-chan struct{} {
1613 return a.ready
1614}
1615
Philip Zeyligerbe7802a2025-06-04 20:15:25 +00001616// BranchPrefix returns the configured branch prefix
1617func (a *Agent) BranchPrefix() string {
1618 return a.config.BranchPrefix
1619}
1620
philip.zeyliger6d3de482025-06-10 19:38:14 -07001621// LinkToGitHub returns whether GitHub branch linking is enabled
1622func (a *Agent) LinkToGitHub() bool {
1623 return a.config.LinkToGitHub
1624}
1625
Earl Lee2e463fb2025-04-17 11:22:22 -07001626func (a *Agent) UserMessage(ctx context.Context, msg string) {
1627 a.pushToOutbox(ctx, AgentMessage{Type: UserMessageType, Content: msg})
1628 a.inbox <- msg
1629}
1630
Earl Lee2e463fb2025-04-17 11:22:22 -07001631func (a *Agent) CancelToolUse(toolUseID string, cause error) error {
1632 return a.convo.CancelToolUse(toolUseID, cause)
1633}
1634
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001635func (a *Agent) CancelTurn(cause error) {
1636 a.cancelTurnMu.Lock()
1637 defer a.cancelTurnMu.Unlock()
1638 if a.cancelTurn != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001639 // Force state transition to cancelled state
1640 ctx := a.config.Context
1641 a.stateMachine.ForceTransition(ctx, StateCancelled, "User cancelled turn: "+cause.Error())
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001642 a.cancelTurn(cause)
Earl Lee2e463fb2025-04-17 11:22:22 -07001643 }
1644}
1645
1646func (a *Agent) Loop(ctxOuter context.Context) {
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001647 // Start port monitoring
1648 if a.portMonitor != nil && a.IsInContainer() {
1649 if err := a.portMonitor.Start(ctxOuter); err != nil {
1650 slog.WarnContext(ctxOuter, "Failed to start port monitor", "error", err)
1651 } else {
1652 slog.InfoContext(ctxOuter, "Port monitor started")
1653 }
1654 }
1655
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001656 // Set up cleanup when context is done
1657 defer func() {
1658 if a.mcpManager != nil {
1659 a.mcpManager.Close()
1660 }
Philip Zeyliger5f26a342025-07-04 01:30:29 +00001661 if a.portMonitor != nil && a.IsInContainer() {
1662 a.portMonitor.Stop()
1663 }
Philip Zeyliger194bfa82025-06-24 06:03:06 -07001664 }()
1665
Earl Lee2e463fb2025-04-17 11:22:22 -07001666 for {
1667 select {
1668 case <-ctxOuter.Done():
1669 return
1670 default:
1671 ctxInner, cancel := context.WithCancelCause(ctxOuter)
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001672 a.cancelTurnMu.Lock()
1673 // Set .cancelTurn so the user can cancel whatever is happening
Sean McCullough885a16a2025-04-30 02:49:25 +00001674 // inside the conversation loop without canceling this outer Loop execution.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001675 // This cancelTurn func is intended be called from other goroutines,
Earl Lee2e463fb2025-04-17 11:22:22 -07001676 // hence the mutex.
Sean McCulloughedc88dc2025-04-30 02:55:01 +00001677 a.cancelTurn = cancel
1678 a.cancelTurnMu.Unlock()
Sean McCullough9f4b8082025-04-30 17:34:07 +00001679 err := a.processTurn(ctxInner) // Renamed from InnerLoop to better reflect its purpose
1680 if err != nil {
1681 slog.ErrorContext(ctxOuter, "Error in processing turn", "error", err)
1682 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001683 cancel(nil)
1684 }
1685 }
1686}
1687
1688func (a *Agent) pushToOutbox(ctx context.Context, m AgentMessage) {
1689 if m.Timestamp.IsZero() {
1690 m.Timestamp = time.Now()
1691 }
1692
Philip Zeyliger72252cb2025-05-10 17:00:08 -07001693 // If this is a ToolUseMessage and ToolResult is set but Content is not, copy the ToolResult to Content
1694 if m.Type == ToolUseMessageType && m.ToolResult != "" && m.Content == "" {
1695 m.Content = m.ToolResult
1696 }
1697
Earl Lee2e463fb2025-04-17 11:22:22 -07001698 // If this is an end-of-turn message, calculate the turn duration and add it to the message
1699 if m.EndOfTurn && m.Type == AgentMessageType {
1700 turnDuration := time.Since(a.startOfTurn)
1701 m.TurnDuration = &turnDuration
1702 slog.InfoContext(ctx, "Turn completed", "turnDuration", turnDuration)
1703 }
1704
Earl Lee2e463fb2025-04-17 11:22:22 -07001705 a.mu.Lock()
1706 defer a.mu.Unlock()
1707 m.Idx = len(a.history)
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001708 slog.InfoContext(ctx, "agent message", m.Attr())
Earl Lee2e463fb2025-04-17 11:22:22 -07001709 a.history = append(a.history, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07001710
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001711 // Notify all subscribers
1712 for _, ch := range a.subscribers {
1713 ch <- &m
Earl Lee2e463fb2025-04-17 11:22:22 -07001714 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001715}
1716
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001717func (a *Agent) GatherMessages(ctx context.Context, block bool) ([]llm.Content, error) {
1718 var m []llm.Content
Earl Lee2e463fb2025-04-17 11:22:22 -07001719 if block {
1720 select {
1721 case <-ctx.Done():
1722 return m, ctx.Err()
1723 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001724 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001725 }
1726 }
1727 for {
1728 select {
1729 case msg := <-a.inbox:
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001730 m = append(m, llm.StringContent(msg))
Earl Lee2e463fb2025-04-17 11:22:22 -07001731 default:
1732 return m, nil
1733 }
1734 }
1735}
1736
Sean McCullough885a16a2025-04-30 02:49:25 +00001737// processTurn handles a single conversation turn with the user
Sean McCullough9f4b8082025-04-30 17:34:07 +00001738func (a *Agent) processTurn(ctx context.Context) error {
Earl Lee2e463fb2025-04-17 11:22:22 -07001739 // Reset the start of turn time
1740 a.startOfTurn = time.Now()
1741
Sean McCullough96b60dd2025-04-30 09:49:10 -07001742 // Transition to waiting for user input state
1743 a.stateMachine.Transition(ctx, StateWaitingForUserInput, "Starting turn")
1744
Sean McCullough885a16a2025-04-30 02:49:25 +00001745 // Process initial user message
1746 initialResp, err := a.processUserMessage(ctx)
1747 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001748 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001749 return err
1750 }
1751
1752 // Handle edge case where both initialResp and err are nil
1753 if initialResp == nil {
1754 err := fmt.Errorf("unexpected nil response from processUserMessage with no error")
Sean McCullough96b60dd2025-04-30 09:49:10 -07001755 a.stateMachine.Transition(ctx, StateError, "Error processing user message: "+err.Error())
1756
Sean McCullough9f4b8082025-04-30 17:34:07 +00001757 a.pushToOutbox(ctx, errorMessage(err))
1758 return err
Earl Lee2e463fb2025-04-17 11:22:22 -07001759 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001760
Earl Lee2e463fb2025-04-17 11:22:22 -07001761 // We do this as we go, but let's also do it at the end of the turn
1762 defer func() {
1763 if _, err := a.handleGitCommits(ctx); err != nil {
1764 // Just log the error, don't stop execution
1765 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1766 }
1767 }()
1768
Sean McCullougha1e0e492025-05-01 10:51:08 -07001769 // Main response loop - continue as long as the model is using tools or a tool use fails.
Sean McCullough885a16a2025-04-30 02:49:25 +00001770 resp := initialResp
1771 for {
1772 // Check if we are over budget
1773 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001774 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Sean McCullough9f4b8082025-04-30 17:34:07 +00001775 return err
Sean McCullough885a16a2025-04-30 02:49:25 +00001776 }
1777
Philip Zeyligerb8a8f352025-06-02 07:39:37 -07001778 // Check if we should compact the conversation
1779 if a.ShouldCompact() {
1780 a.stateMachine.Transition(ctx, StateCompacting, "Token usage threshold reached, compacting conversation")
1781 if err := a.CompactConversation(ctx); err != nil {
1782 a.stateMachine.Transition(ctx, StateError, "Error during compaction: "+err.Error())
1783 return err
1784 }
1785 // After compaction, end this turn and start fresh
1786 a.stateMachine.Transition(ctx, StateEndOfTurn, "Compaction completed, ending turn")
1787 return nil
1788 }
1789
Sean McCullough885a16a2025-04-30 02:49:25 +00001790 // If the model is not requesting to use a tool, we're done
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001791 if resp.StopReason != llm.StopReasonToolUse {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001792 a.stateMachine.Transition(ctx, StateEndOfTurn, "LLM completed response, ending turn")
Sean McCullough885a16a2025-04-30 02:49:25 +00001793 break
1794 }
1795
Sean McCullough96b60dd2025-04-30 09:49:10 -07001796 // Transition to tool use requested state
1797 a.stateMachine.Transition(ctx, StateToolUseRequested, "LLM requested tool use")
1798
Sean McCullough885a16a2025-04-30 02:49:25 +00001799 // Handle tool execution
1800 continueConversation, toolResp := a.handleToolExecution(ctx, resp)
1801 if !continueConversation {
Sean McCullough9f4b8082025-04-30 17:34:07 +00001802 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001803 }
1804
Sean McCullougha1e0e492025-05-01 10:51:08 -07001805 if toolResp == nil {
1806 return fmt.Errorf("cannot continue conversation with a nil tool response")
1807 }
1808
Sean McCullough885a16a2025-04-30 02:49:25 +00001809 // Set the response for the next iteration
1810 resp = toolResp
1811 }
Sean McCullough9f4b8082025-04-30 17:34:07 +00001812
1813 return nil
Sean McCullough885a16a2025-04-30 02:49:25 +00001814}
1815
1816// processUserMessage waits for user messages and sends them to the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001817func (a *Agent) processUserMessage(ctx context.Context) (*llm.Response, error) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001818 // Wait for at least one message from the user
1819 msgs, err := a.GatherMessages(ctx, true)
1820 if err != nil { // e.g. the context was canceled while blocking in GatherMessages
Sean McCullough96b60dd2025-04-30 09:49:10 -07001821 a.stateMachine.Transition(ctx, StateError, "Error gathering messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001822 return nil, err
1823 }
1824
Josh Bleecher Snyder3b44cc32025-07-22 02:28:14 +00001825 // Auto-generate slug if this is the first user input and no slug is set
1826 if a.Slug() == "" {
1827 if err := a.autoGenerateSlug(ctx, msgs); err != nil {
1828 // NB: it is possible that autoGenerateSlug set the slug during the process
1829 // of trying to generate a slug.
1830 // The fact that it returned an error means that we cannot use that slug.
1831 slog.WarnContext(ctx, "Failed to auto-generate slug", "error", err)
1832 // use the session id instead. ugly, but we need a slug, and this will be unique.
1833 a.SetSlug(a.SessionID())
1834 }
1835 // Notify termui of the final slug (only emitted once, after slug is determined)
1836 a.pushToOutbox(ctx, AgentMessage{
1837 Type: SlugMessageType,
1838 Content: a.Slug(),
1839 })
1840 }
1841
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001842 userMessage := llm.Message{
1843 Role: llm.MessageRoleUser,
Earl Lee2e463fb2025-04-17 11:22:22 -07001844 Content: msgs,
1845 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001846
Sean McCullough96b60dd2025-04-30 09:49:10 -07001847 // Transition to sending to LLM state
1848 a.stateMachine.Transition(ctx, StateSendingToLLM, "Sending user message to LLM")
1849
Sean McCullough885a16a2025-04-30 02:49:25 +00001850 // Send message to the model
Earl Lee2e463fb2025-04-17 11:22:22 -07001851 resp, err := a.convo.SendMessage(userMessage)
1852 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001853 a.stateMachine.Transition(ctx, StateError, "Error sending to LLM: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07001854 a.pushToOutbox(ctx, errorMessage(err))
Sean McCullough885a16a2025-04-30 02:49:25 +00001855 return nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07001856 }
Earl Lee2e463fb2025-04-17 11:22:22 -07001857
Sean McCullough96b60dd2025-04-30 09:49:10 -07001858 // Transition to processing LLM response state
1859 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response")
1860
Sean McCullough885a16a2025-04-30 02:49:25 +00001861 return resp, nil
1862}
1863
1864// handleToolExecution processes a tool use request from the model
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001865func (a *Agent) handleToolExecution(ctx context.Context, resp *llm.Response) (bool, *llm.Response) {
1866 var results []llm.Content
Sean McCullough885a16a2025-04-30 02:49:25 +00001867 cancelled := false
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001868 toolEndsTurn := false
Sean McCullough885a16a2025-04-30 02:49:25 +00001869
Sean McCullough96b60dd2025-04-30 09:49:10 -07001870 // Transition to checking for cancellation state
1871 a.stateMachine.Transition(ctx, StateCheckingForCancellation, "Checking if user requested cancellation")
1872
Sean McCullough885a16a2025-04-30 02:49:25 +00001873 // Check if the operation was cancelled by the user
1874 select {
1875 case <-ctx.Done():
1876 // Don't actually run any of the tools, but rather build a response
1877 // for each tool_use message letting the LLM know that user canceled it.
1878 var err error
1879 results, err = a.convo.ToolResultCancelContents(resp)
Earl Lee2e463fb2025-04-17 11:22:22 -07001880 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001881 a.stateMachine.Transition(ctx, StateError, "Error creating cancellation response: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001882 a.pushToOutbox(ctx, errorMessage(err))
Earl Lee2e463fb2025-04-17 11:22:22 -07001883 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001884 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001885 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled by user")
Sean McCullough885a16a2025-04-30 02:49:25 +00001886 default:
Sean McCullough96b60dd2025-04-30 09:49:10 -07001887 // Transition to running tool state
1888 a.stateMachine.Transition(ctx, StateRunningTool, "Executing requested tool")
1889
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001890 // Add working directory and session ID to context for tool execution
Sean McCullough885a16a2025-04-30 02:49:25 +00001891 ctx = claudetool.WithWorkingDir(ctx, a.workingDir)
Josh Bleecher Snyder112b9232025-05-23 11:26:33 -07001892 ctx = claudetool.WithSessionID(ctx, a.config.SessionID)
Sean McCullough885a16a2025-04-30 02:49:25 +00001893
1894 // Execute the tools
1895 var err error
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001896 results, toolEndsTurn, err = a.convo.ToolResultContents(ctx, resp)
Sean McCullough885a16a2025-04-30 02:49:25 +00001897 if ctx.Err() != nil { // e.g. the user canceled the operation
1898 cancelled = true
Sean McCullough96b60dd2025-04-30 09:49:10 -07001899 a.stateMachine.Transition(ctx, StateCancelled, "Operation cancelled during tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001900 } else if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001901 a.stateMachine.Transition(ctx, StateError, "Error executing tool: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001902 a.pushToOutbox(ctx, errorMessage(err))
1903 }
1904 }
1905
1906 // Process git commits that may have occurred during tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001907 a.stateMachine.Transition(ctx, StateCheckingGitCommits, "Checking for git commits")
Sean McCullough885a16a2025-04-30 02:49:25 +00001908 autoqualityMessages := a.processGitChanges(ctx)
1909
1910 // Check budget again after tool execution
Sean McCullough96b60dd2025-04-30 09:49:10 -07001911 a.stateMachine.Transition(ctx, StateCheckingBudget, "Checking budget after tool execution")
Sean McCullough885a16a2025-04-30 02:49:25 +00001912 if err := a.overBudget(ctx); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001913 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded after tool execution: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001914 return false, nil
1915 }
1916
1917 // Continue the conversation with tool results and any user messages
Josh Bleecher Snyder64f2aa82025-05-14 18:31:05 +00001918 shouldContinue, resp := a.continueTurnWithToolResults(ctx, results, autoqualityMessages, cancelled)
1919 return shouldContinue && !toolEndsTurn, resp
Sean McCullough885a16a2025-04-30 02:49:25 +00001920}
1921
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001922// DetectGitChanges checks for new git commits and pushes them if found
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001923func (a *Agent) DetectGitChanges(ctx context.Context) error {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001924 // Check for git commits
1925 _, err := a.handleGitCommits(ctx)
1926 if err != nil {
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001927 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001928 return fmt.Errorf("failed to check for new git commits: %w", err)
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001929 }
Philip Zeyliger9bca61e2025-05-22 12:40:06 -07001930 return nil
Philip Zeyliger75bd37d2025-05-22 18:49:14 +00001931}
1932
1933// processGitChanges checks for new git commits, runs autoformatters if needed, and returns any messages generated
1934// This is used internally by the agent loop
Sean McCullough885a16a2025-04-30 02:49:25 +00001935func (a *Agent) processGitChanges(ctx context.Context) []string {
1936 // Check for git commits after tool execution
1937 newCommits, err := a.handleGitCommits(ctx)
1938 if err != nil {
1939 // Just log the error, don't stop execution
1940 slog.WarnContext(ctx, "Failed to check for new git commits", "error", err)
1941 return nil
1942 }
1943
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001944 // Run mechanical checks if there was exactly one new commit.
1945 if len(newCommits) != 1 {
1946 return nil
1947 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001948 var autoqualityMessages []string
Josh Bleecher Snyderc72ceb22025-05-05 23:30:15 +00001949 a.stateMachine.Transition(ctx, StateRunningAutoformatters, "Running mechanical checks on new commit")
1950 msg := a.codereview.RunMechanicalChecks(ctx)
1951 if msg != "" {
1952 a.pushToOutbox(ctx, AgentMessage{
1953 Type: AutoMessageType,
1954 Content: msg,
1955 Timestamp: time.Now(),
1956 })
1957 autoqualityMessages = append(autoqualityMessages, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07001958 }
Sean McCullough885a16a2025-04-30 02:49:25 +00001959
1960 return autoqualityMessages
1961}
1962
1963// continueTurnWithToolResults continues the conversation with tool results
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001964func (a *Agent) continueTurnWithToolResults(ctx context.Context, results []llm.Content, autoqualityMessages []string, cancelled bool) (bool, *llm.Response) {
Sean McCullough885a16a2025-04-30 02:49:25 +00001965 // Get any messages the user sent while tools were executing
Sean McCullough96b60dd2025-04-30 09:49:10 -07001966 a.stateMachine.Transition(ctx, StateGatheringAdditionalMessages, "Gathering additional user messages")
Sean McCullough885a16a2025-04-30 02:49:25 +00001967 msgs, err := a.GatherMessages(ctx, false)
1968 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07001969 a.stateMachine.Transition(ctx, StateError, "Error gathering additional messages: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00001970 return false, nil
1971 }
1972
1973 // Inject any auto-generated messages from quality checks
1974 for _, msg := range autoqualityMessages {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001975 msgs = append(msgs, llm.StringContent(msg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001976 }
1977
1978 // Handle cancellation by appending a message about it
1979 if cancelled {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001980 msgs = append(msgs, llm.StringContent(cancelToolUseMessage))
Sean McCullough885a16a2025-04-30 02:49:25 +00001981 // EndOfTurn is false here so that the client of this agent keeps processing
Philip Zeyligerb7c58752025-05-01 10:10:17 -07001982 // further messages; the conversation is not over.
Sean McCullough885a16a2025-04-30 02:49:25 +00001983 a.pushToOutbox(ctx, AgentMessage{Type: ErrorMessageType, Content: userCancelMessage, EndOfTurn: false})
1984 } else if err := a.convo.OverBudget(); err != nil {
1985 // Handle budget issues by appending a message about it
1986 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 -07001987 msgs = append(msgs, llm.StringContent(budgetMsg))
Sean McCullough885a16a2025-04-30 02:49:25 +00001988 a.pushToOutbox(ctx, budgetMessage(fmt.Errorf("warning: %w (ask to keep trying, if you'd like)", err)))
1989 }
1990
1991 // Combine tool results with user messages
1992 results = append(results, msgs...)
1993
1994 // Send the combined message to continue the conversation
Sean McCullough96b60dd2025-04-30 09:49:10 -07001995 a.stateMachine.Transition(ctx, StateSendingToolResults, "Sending tool results back to LLM")
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07001996 resp, err := a.convo.SendMessage(llm.Message{
1997 Role: llm.MessageRoleUser,
Sean McCullough885a16a2025-04-30 02:49:25 +00001998 Content: results,
1999 })
2000 if err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07002001 a.stateMachine.Transition(ctx, StateError, "Error sending tool results: "+err.Error())
Sean McCullough885a16a2025-04-30 02:49:25 +00002002 a.pushToOutbox(ctx, errorMessage(fmt.Errorf("error: failed to continue conversation: %s", err.Error())))
2003 return true, nil // Return true to continue the conversation, but with no response
2004 }
2005
Sean McCullough96b60dd2025-04-30 09:49:10 -07002006 // Transition back to processing LLM response
2007 a.stateMachine.Transition(ctx, StateProcessingLLMResponse, "Processing LLM response to tool results")
2008
Sean McCullough885a16a2025-04-30 02:49:25 +00002009 if cancelled {
2010 return false, nil
2011 }
2012
2013 return true, resp
Earl Lee2e463fb2025-04-17 11:22:22 -07002014}
2015
2016func (a *Agent) overBudget(ctx context.Context) error {
2017 if err := a.convo.OverBudget(); err != nil {
Sean McCullough96b60dd2025-04-30 09:49:10 -07002018 a.stateMachine.Transition(ctx, StateBudgetExceeded, "Budget exceeded: "+err.Error())
Earl Lee2e463fb2025-04-17 11:22:22 -07002019 m := budgetMessage(err)
2020 m.Content = m.Content + "\n\nBudget reset."
David Crawshaw35c72bc2025-05-20 11:17:10 -07002021 a.pushToOutbox(ctx, m)
Earl Lee2e463fb2025-04-17 11:22:22 -07002022 a.convo.ResetBudget(a.originalBudget)
2023 return err
2024 }
2025 return nil
2026}
2027
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002028func collectTextContent(msg *llm.Response) string {
Earl Lee2e463fb2025-04-17 11:22:22 -07002029 // Collect all text content
2030 var allText strings.Builder
2031 for _, content := range msg.Content {
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002032 if content.Type == llm.ContentTypeText && content.Text != "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07002033 if allText.Len() > 0 {
2034 allText.WriteString("\n\n")
2035 }
2036 allText.WriteString(content.Text)
2037 }
2038 }
2039 return allText.String()
2040}
2041
Josh Bleecher Snyder4f84ab72025-04-22 16:40:54 -07002042func (a *Agent) TotalUsage() conversation.CumulativeUsage {
Earl Lee2e463fb2025-04-17 11:22:22 -07002043 a.mu.Lock()
2044 defer a.mu.Unlock()
2045 return a.convo.CumulativeUsage()
2046}
2047
Earl Lee2e463fb2025-04-17 11:22:22 -07002048// Diff returns a unified diff of changes made since the agent was instantiated.
2049func (a *Agent) Diff(commit *string) (string, error) {
Philip Zeyliger49edc922025-05-14 09:45:45 -07002050 if a.SketchGitBase() == "" {
Earl Lee2e463fb2025-04-17 11:22:22 -07002051 return "", fmt.Errorf("no initial commit reference available")
2052 }
2053
2054 // Find the repository root
2055 ctx := context.Background()
2056
2057 // If a specific commit hash is provided, show just that commit's changes
2058 if commit != nil && *commit != "" {
2059 // Validate that the commit looks like a valid git SHA
2060 if !isValidGitSHA(*commit) {
2061 return "", fmt.Errorf("invalid git commit SHA format: %s", *commit)
2062 }
2063
2064 // Get the diff for just this commit
2065 cmd := exec.CommandContext(ctx, "git", "show", "--unified=10", *commit)
2066 cmd.Dir = a.repoRoot
2067 output, err := cmd.CombinedOutput()
2068 if err != nil {
2069 return "", fmt.Errorf("failed to get diff for commit %s: %w - %s", *commit, err, string(output))
2070 }
2071 return string(output), nil
2072 }
2073
2074 // Otherwise, get the diff between the initial commit and the current state using exec.Command
Philip Zeyliger49edc922025-05-14 09:45:45 -07002075 cmd := exec.CommandContext(ctx, "git", "diff", "--unified=10", a.SketchGitBaseRef())
Earl Lee2e463fb2025-04-17 11:22:22 -07002076 cmd.Dir = a.repoRoot
2077 output, err := cmd.CombinedOutput()
2078 if err != nil {
2079 return "", fmt.Errorf("failed to get diff: %w - %s", err, string(output))
2080 }
2081
2082 return string(output), nil
2083}
2084
Philip Zeyliger49edc922025-05-14 09:45:45 -07002085// SketchGitBaseRef distinguishes between the typical container version, where sketch-base is
2086// unambiguous, and the "unsafe" version, where we need to use a session id to disambiguate.
2087func (a *Agent) SketchGitBaseRef() string {
2088 if a.IsInContainer() {
2089 return "sketch-base"
2090 } else {
2091 return "sketch-base-" + a.SessionID()
2092 }
2093}
2094
2095// SketchGitBase returns the Git commit hash that was saved when the agent was instantiated.
2096func (a *Agent) SketchGitBase() string {
2097 cmd := exec.CommandContext(context.Background(), "git", "rev-parse", a.SketchGitBaseRef())
2098 cmd.Dir = a.repoRoot
2099 output, err := cmd.CombinedOutput()
2100 if err != nil {
2101 slog.Warn("could not identify sketch-base", slog.String("error", err.Error()))
2102 return "HEAD"
2103 }
2104 return string(strings.TrimSpace(string(output)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002105}
2106
Pokey Rule7a113622025-05-12 10:58:45 +01002107// removeGitHooks removes the Git hooks directory from the repository
2108func removeGitHooks(_ context.Context, repoPath string) error {
2109 hooksDir := filepath.Join(repoPath, ".git", "hooks")
2110
2111 // Check if hooks directory exists
2112 if _, err := os.Stat(hooksDir); os.IsNotExist(err) {
2113 // Directory doesn't exist, nothing to do
2114 return nil
2115 }
2116
2117 // Remove the hooks directory
2118 err := os.RemoveAll(hooksDir)
2119 if err != nil {
2120 return fmt.Errorf("failed to remove git hooks directory: %w", err)
2121 }
2122
2123 // Create an empty hooks directory to prevent git from recreating default hooks
Autoformattere577ef72025-05-12 10:29:00 +00002124 err = os.MkdirAll(hooksDir, 0o755)
Pokey Rule7a113622025-05-12 10:58:45 +01002125 if err != nil {
2126 return fmt.Errorf("failed to create empty git hooks directory: %w", err)
2127 }
2128
2129 return nil
2130}
2131
Philip Zeyligerf2872992025-05-22 10:35:28 -07002132func (a *Agent) handleGitCommits(ctx context.Context) ([]*GitCommit, error) {
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002133 msgs, commits, error := a.gitState.handleGitCommits(ctx, a.repoRoot, a.SketchGitBaseRef(), a.config.BranchPrefix)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002134 for _, msg := range msgs {
2135 a.pushToOutbox(ctx, msg)
2136 }
2137 return commits, error
2138}
2139
Earl Lee2e463fb2025-04-17 11:22:22 -07002140// handleGitCommits() highlights new commits to the user. When running
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002141// under docker, new HEADs are pushed to a branch according to the slug.
Josh Bleecher Snydereb91caa2025-07-11 15:29:18 -07002142func (ags *AgentGitState) handleGitCommits(ctx context.Context, repoRoot string, baseRef string, branchPrefix string) ([]AgentMessage, []*GitCommit, error) {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002143 ags.mu.Lock()
2144 defer ags.mu.Unlock()
2145
2146 msgs := []AgentMessage{}
2147 if repoRoot == "" {
2148 return msgs, nil, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002149 }
2150
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002151 sketch, err := resolveRef(ctx, repoRoot, "sketch-wip")
Earl Lee2e463fb2025-04-17 11:22:22 -07002152 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002153 return msgs, nil, err
Earl Lee2e463fb2025-04-17 11:22:22 -07002154 }
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002155 if sketch == ags.lastSketch {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002156 return msgs, nil, nil // nothing to do
Earl Lee2e463fb2025-04-17 11:22:22 -07002157 }
2158 defer func() {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002159 ags.lastSketch = sketch
Earl Lee2e463fb2025-04-17 11:22:22 -07002160 }()
2161
Philip Zeyliger64f60462025-06-16 13:57:10 -07002162 // Compute diff stats from baseRef to HEAD when HEAD changes
2163 if added, removed, err := computeDiffStats(ctx, repoRoot, baseRef); err != nil {
2164 // Log error but don't fail the entire operation
2165 slog.WarnContext(ctx, "Failed to compute diff stats", "error", err)
2166 } else {
2167 // Set diff stats directly since we already hold the mutex
2168 ags.linesAdded = added
2169 ags.linesRemoved = removed
2170 }
2171
Earl Lee2e463fb2025-04-17 11:22:22 -07002172 // Get new commits. Because it's possible that the agent does rebases, fixups, and
2173 // so forth, we use, as our fixed point, the "initialCommit", and we limit ourselves
2174 // to the last 100 commits.
2175 var commits []*GitCommit
2176
2177 // Get commits since the initial commit
2178 // Format: <hash>\0<subject>\0<body>\0
2179 // This uses NULL bytes as separators to avoid issues with newlines in commit messages
2180 // Limit to 100 commits to avoid overwhelming the user
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002181 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 -07002182 cmd.Dir = repoRoot
Earl Lee2e463fb2025-04-17 11:22:22 -07002183 output, err := cmd.Output()
2184 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002185 return msgs, nil, fmt.Errorf("failed to get git log: %w", err)
Earl Lee2e463fb2025-04-17 11:22:22 -07002186 }
2187
2188 // Parse git log output and filter out already seen commits
2189 parsedCommits := parseGitLog(string(output))
2190
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002191 var sketchCommit *GitCommit
Earl Lee2e463fb2025-04-17 11:22:22 -07002192
2193 // Filter out commits we've already seen
2194 for _, commit := range parsedCommits {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002195 if commit.Hash == sketch {
2196 sketchCommit = &commit
Earl Lee2e463fb2025-04-17 11:22:22 -07002197 }
2198
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002199 // Skip if we've seen this commit before. If our sketch branch has changed, always include that.
2200 if ags.seenCommits[commit.Hash] && commit.Hash != sketch {
Earl Lee2e463fb2025-04-17 11:22:22 -07002201 continue
2202 }
2203
2204 // Mark this commit as seen
Philip Zeyligerf2872992025-05-22 10:35:28 -07002205 ags.seenCommits[commit.Hash] = true
Earl Lee2e463fb2025-04-17 11:22:22 -07002206
2207 // Add to our list of new commits
2208 commits = append(commits, &commit)
2209 }
2210
Philip Zeyligerf2872992025-05-22 10:35:28 -07002211 if ags.gitRemoteAddr != "" {
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002212 if sketchCommit == nil {
Earl Lee2e463fb2025-04-17 11:22:22 -07002213 // 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 -07002214 sketchCommit = &GitCommit{}
2215 sketchCommit.Hash = sketch
2216 sketchCommit.Subject = "unknown"
2217 commits = append(commits, sketchCommit)
Earl Lee2e463fb2025-04-17 11:22:22 -07002218 }
2219
Earl Lee2e463fb2025-04-17 11:22:22 -07002220 // TODO: I don't love the force push here. We could see if the push is a fast-forward, and,
2221 // if it's not, we could make a backup with a unique name (perhaps append a timestamp) and
2222 // then use push with lease to replace.
Philip Zeyliger113e2052025-05-09 21:59:40 +00002223
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002224 // 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 +00002225 var out []byte
2226 var err error
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002227 originalRetryNumber := ags.retryNumber
2228 originalBranchName := ags.branchNameLocked(branchPrefix)
Philip Zeyliger113e2052025-05-09 21:59:40 +00002229 for retries := range 10 {
2230 if retries > 0 {
Philip Zeyligerd5c8d712025-06-17 15:19:45 -07002231 ags.retryNumber++
Philip Zeyliger113e2052025-05-09 21:59:40 +00002232 }
2233
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002234 branch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002235 cmd = exec.Command("git", "push", "--force", ags.gitRemoteAddr, "sketch-wip:refs/heads/"+branch)
Philip Zeyligerf2872992025-05-22 10:35:28 -07002236 cmd.Dir = repoRoot
Philip Zeyliger113e2052025-05-09 21:59:40 +00002237 out, err = cmd.CombinedOutput()
2238
2239 if err == nil {
2240 // Success! Break out of the retry loop
2241 break
2242 }
2243
2244 // Check if this is the "refusing to update checked out branch" error
2245 if !strings.Contains(string(out), "refusing to update checked out branch") {
2246 // This is a different error, so don't retry
2247 break
2248 }
Philip Zeyliger113e2052025-05-09 21:59:40 +00002249 }
2250
2251 if err != nil {
Philip Zeyligerf2872992025-05-22 10:35:28 -07002252 msgs = append(msgs, errorMessage(fmt.Errorf("git push to host: %s: %v", out, err)))
Earl Lee2e463fb2025-04-17 11:22:22 -07002253 } else {
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002254 finalBranch := ags.branchNameLocked(branchPrefix)
Josh Bleecher Snyder715b8d92025-06-06 12:36:38 -07002255 sketchCommit.PushedBranch = finalBranch
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002256 if ags.retryNumber != originalRetryNumber {
2257 // Notify user that the branch name was changed, and why
Philip Zeyliger59e1c162025-06-02 12:54:34 +00002258 msgs = append(msgs, AgentMessage{
2259 Type: AutoMessageType,
2260 Timestamp: time.Now(),
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002261 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 +00002262 })
Philip Zeyliger113e2052025-05-09 21:59:40 +00002263 }
Earl Lee2e463fb2025-04-17 11:22:22 -07002264 }
2265 }
2266
2267 // If we found new commits, create a message
2268 if len(commits) > 0 {
2269 msg := AgentMessage{
2270 Type: CommitMessageType,
2271 Timestamp: time.Now(),
2272 Commits: commits,
2273 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002274 msgs = append(msgs, msg)
Earl Lee2e463fb2025-04-17 11:22:22 -07002275 }
Philip Zeyligerf2872992025-05-22 10:35:28 -07002276 return msgs, commits, nil
Earl Lee2e463fb2025-04-17 11:22:22 -07002277}
2278
Josh Bleecher Snyder19969a92025-06-05 14:34:02 -07002279func cleanSlugName(s string) string {
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002280 return strings.Map(func(r rune) rune {
2281 // lowercase
2282 if r >= 'A' && r <= 'Z' {
2283 return r + 'a' - 'A'
Earl Lee2e463fb2025-04-17 11:22:22 -07002284 }
Josh Bleecher Snyder1ae976b2025-04-30 00:06:43 +00002285 // replace spaces with dashes
2286 if r == ' ' {
2287 return '-'
2288 }
2289 // allow alphanumerics and dashes
2290 if (r >= 'a' && r <= 'z') || r == '-' || (r >= '0' && r <= '9') {
2291 return r
2292 }
2293 return -1
2294 }, s)
Earl Lee2e463fb2025-04-17 11:22:22 -07002295}
2296
2297// parseGitLog parses the output of git log with format '%H%x00%s%x00%b%x00'
2298// and returns an array of GitCommit structs.
2299func parseGitLog(output string) []GitCommit {
2300 var commits []GitCommit
2301
2302 // No output means no commits
2303 if len(output) == 0 {
2304 return commits
2305 }
2306
2307 // Split by NULL byte
2308 parts := strings.Split(output, "\x00")
2309
2310 // Process in triplets (hash, subject, body)
2311 for i := 0; i < len(parts); i++ {
2312 // Skip empty parts
2313 if parts[i] == "" {
2314 continue
2315 }
2316
2317 // This should be a hash
2318 hash := strings.TrimSpace(parts[i])
2319
2320 // Make sure we have at least a subject part available
2321 if i+1 >= len(parts) {
2322 break // No more parts available
2323 }
2324
2325 // Get the subject
2326 subject := strings.TrimSpace(parts[i+1])
2327
2328 // Get the body if available
2329 body := ""
2330 if i+2 < len(parts) {
2331 body = strings.TrimSpace(parts[i+2])
2332 }
2333
2334 // Skip to the next triplet
2335 i += 2
2336
2337 commits = append(commits, GitCommit{
2338 Hash: hash,
2339 Subject: subject,
2340 Body: body,
2341 })
2342 }
2343
2344 return commits
2345}
2346
2347func repoRoot(ctx context.Context, dir string) (string, error) {
2348 cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
2349 stderr := new(strings.Builder)
2350 cmd.Stderr = stderr
2351 cmd.Dir = dir
2352 out, err := cmd.Output()
2353 if err != nil {
Philip Zeyligerbc8c8dc2025-05-21 13:19:13 -07002354 return "", fmt.Errorf("git rev-parse (in %s) failed: %w\n%s", dir, err, stderr)
Earl Lee2e463fb2025-04-17 11:22:22 -07002355 }
2356 return strings.TrimSpace(string(out)), nil
2357}
2358
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002359// upsertRemoteOrigin configures the origin remote to point to the given URL.
2360// If the origin remote exists, it updates the URL. If it doesn't exist, it adds it.
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002361//
2362// NOTE: Maybe we should use an "insteadOf" setting instead of changing the URL.
Josh Bleecher Snyder369f2622025-07-15 00:02:59 +00002363func upsertRemoteOrigin(ctx context.Context, repoDir, remoteURL string) error {
2364 // Try to set the URL for existing origin remote
2365 cmd := exec.CommandContext(ctx, "git", "remote", "set-url", "origin", remoteURL)
2366 cmd.Dir = repoDir
2367 if _, err := cmd.CombinedOutput(); err == nil {
2368 // Success.
2369 return nil
2370 }
2371 // Origin doesn't exist; add it.
2372 cmd = exec.CommandContext(ctx, "git", "remote", "add", "origin", remoteURL)
2373 cmd.Dir = repoDir
2374 if out, err := cmd.CombinedOutput(); err != nil {
2375 return fmt.Errorf("failed to add git remote origin: %s: %w", out, err)
2376 }
2377 return nil
2378}
2379
Earl Lee2e463fb2025-04-17 11:22:22 -07002380func resolveRef(ctx context.Context, dir, refName string) (string, error) {
2381 cmd := exec.CommandContext(ctx, "git", "rev-parse", refName)
2382 stderr := new(strings.Builder)
2383 cmd.Stderr = stderr
2384 cmd.Dir = dir
2385 out, err := cmd.Output()
2386 if err != nil {
2387 return "", fmt.Errorf("git rev-parse failed: %w\n%s", err, stderr)
2388 }
2389 // TODO: validate that out is valid hex
2390 return strings.TrimSpace(string(out)), nil
2391}
2392
2393// isValidGitSHA validates if a string looks like a valid git SHA hash.
2394// Git SHAs are hexadecimal strings of at least 4 characters but typically 7, 8, or 40 characters.
2395func isValidGitSHA(sha string) bool {
2396 // Git SHA must be a hexadecimal string with at least 4 characters
2397 if len(sha) < 4 || len(sha) > 40 {
2398 return false
2399 }
2400
2401 // Check if the string only contains hexadecimal characters
2402 for _, char := range sha {
2403 if !(char >= '0' && char <= '9') && !(char >= 'a' && char <= 'f') && !(char >= 'A' && char <= 'F') {
2404 return false
2405 }
2406 }
2407
2408 return true
2409}
Philip Zeyligerd1402952025-04-23 03:54:37 +00002410
Philip Zeyliger64f60462025-06-16 13:57:10 -07002411// computeDiffStats computes the number of lines added and removed from baseRef to HEAD
2412func computeDiffStats(ctx context.Context, repoRoot, baseRef string) (int, int, error) {
2413 cmd := exec.CommandContext(ctx, "git", "diff", "--numstat", baseRef, "HEAD")
2414 cmd.Dir = repoRoot
2415 out, err := cmd.Output()
2416 if err != nil {
2417 return 0, 0, fmt.Errorf("git diff --numstat failed: %w", err)
2418 }
2419
2420 var totalAdded, totalRemoved int
2421 lines := strings.Split(strings.TrimSpace(string(out)), "\n")
2422 for _, line := range lines {
2423 if line == "" {
2424 continue
2425 }
2426 parts := strings.Fields(line)
2427 if len(parts) < 2 {
2428 continue
2429 }
2430 // Format: <added>\t<removed>\t<filename>
2431 if added, err := strconv.Atoi(parts[0]); err == nil {
2432 totalAdded += added
2433 }
2434 if removed, err := strconv.Atoi(parts[1]); err == nil {
2435 totalRemoved += removed
2436 }
2437 }
2438
2439 return totalAdded, totalRemoved, nil
2440}
2441
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002442// systemPromptData contains the data used to render the system prompt template
2443type systemPromptData struct {
David Crawshawc886ac52025-06-13 23:40:03 +00002444 ClientGOOS string
2445 ClientGOARCH string
2446 WorkingDir string
2447 RepoRoot string
2448 InitialCommit string
2449 Codebase *onstart.Codebase
2450 UseSketchWIP bool
Philip Zeyligere67e3b62025-07-24 16:54:21 -07002451 InstallationNudge bool
David Crawshawc886ac52025-06-13 23:40:03 +00002452 Branch string
2453 SpecialInstruction string
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +00002454 Now string
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002455}
2456
2457// renderSystemPrompt renders the system prompt template.
2458func (a *Agent) renderSystemPrompt() string {
Josh Bleecher Snyder8a0de522025-07-24 19:29:07 +00002459 nowFn := a.now
2460 if nowFn == nil {
2461 nowFn = time.Now
2462 }
2463 now := nowFn()
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002464 data := systemPromptData{
Philip Zeyligere67e3b62025-07-24 16:54:21 -07002465 ClientGOOS: a.config.ClientGOOS,
2466 ClientGOARCH: a.config.ClientGOARCH,
2467 WorkingDir: a.workingDir,
2468 RepoRoot: a.repoRoot,
2469 InitialCommit: a.SketchGitBase(),
2470 Codebase: a.codebase,
2471 UseSketchWIP: a.config.InDocker,
2472 InstallationNudge: a.config.InDocker,
Josh Bleecher Snyder9224eb02025-07-26 04:45:05 +00002473 Now: now.Format(time.DateOnly),
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002474 }
David Crawshawc886ac52025-06-13 23:40:03 +00002475 if now.Month() == time.September && now.Day() == 19 {
Josh Bleecher Snyder783ab312025-07-25 07:22:38 -07002476 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 +00002477 }
2478
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002479 tmpl, err := template.New("system").Parse(agentSystemPrompt)
2480 if err != nil {
2481 panic(fmt.Sprintf("failed to parse system prompt template: %v", err))
2482 }
2483 buf := new(strings.Builder)
2484 err = tmpl.Execute(buf, data)
2485 if err != nil {
2486 panic(fmt.Sprintf("failed to execute system prompt template: %v", err))
2487 }
Josh Bleecher Snydera997be62025-05-07 22:52:46 +00002488 // fmt.Printf("system prompt: %s\n", buf.String())
Josh Bleecher Snyder5cca56f2025-05-06 01:10:16 +00002489 return buf.String()
2490}
Philip Zeyligereab12de2025-05-14 02:35:53 +00002491
2492// StateTransitionIterator provides an iterator over state transitions.
2493type StateTransitionIterator interface {
2494 // Next blocks until a new state transition is available or context is done.
2495 // Returns nil if the context is cancelled.
2496 Next() *StateTransition
2497 // Close removes the listener and cleans up resources.
2498 Close()
2499}
2500
2501// StateTransitionIteratorImpl implements StateTransitionIterator using a state machine listener.
2502type StateTransitionIteratorImpl struct {
2503 agent *Agent
2504 ctx context.Context
2505 ch chan StateTransition
2506 unsubscribe func()
2507}
2508
2509// Next blocks until a new state transition is available or the context is cancelled.
2510func (s *StateTransitionIteratorImpl) Next() *StateTransition {
2511 select {
2512 case <-s.ctx.Done():
2513 return nil
2514 case transition, ok := <-s.ch:
2515 if !ok {
2516 return nil
2517 }
2518 transitionCopy := transition
2519 return &transitionCopy
2520 }
2521}
2522
2523// Close removes the listener and cleans up resources.
2524func (s *StateTransitionIteratorImpl) Close() {
2525 if s.unsubscribe != nil {
2526 s.unsubscribe()
2527 s.unsubscribe = nil
2528 }
2529}
2530
2531// NewStateTransitionIterator returns an iterator that receives state transitions.
2532func (a *Agent) NewStateTransitionIterator(ctx context.Context) StateTransitionIterator {
2533 a.mu.Lock()
2534 defer a.mu.Unlock()
2535
2536 // Create channel to receive state transitions
2537 ch := make(chan StateTransition, 10)
2538
2539 // Add a listener to the state machine
2540 unsubscribe := a.stateMachine.AddTransitionListener(ch)
2541
2542 return &StateTransitionIteratorImpl{
2543 agent: a,
2544 ctx: ctx,
2545 ch: ch,
2546 unsubscribe: unsubscribe,
2547 }
2548}
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002549
2550// setupGitHooks creates or updates git hooks in the specified working directory.
2551func setupGitHooks(workingDir string) error {
2552 hooksDir := filepath.Join(workingDir, ".git", "hooks")
2553
2554 _, err := os.Stat(hooksDir)
2555 if os.IsNotExist(err) {
2556 return fmt.Errorf("git hooks directory does not exist: %s", hooksDir)
2557 }
2558 if err != nil {
2559 return fmt.Errorf("error checking git hooks directory: %w", err)
2560 }
2561
2562 // Define the post-commit hook content
2563 postCommitHook := `#!/bin/bash
2564echo "<post_commit_hook>"
2565echo "Please review this commit message and fix it if it is incorrect."
2566echo "This hook only echos the commit message; it does not modify it."
2567echo "Bash escaping is a common source of issues; to fix that, create a temp file and use 'git commit --amend -F COMMIT_MSG_FILE'."
2568echo "<last_commit_message>"
Philip Zeyliger6c5beff2025-06-06 13:03:49 -07002569PAGER=cat git log -1 --pretty=%B
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002570echo "</last_commit_message>"
2571echo "</post_commit_hook>"
2572`
2573
2574 // Define the prepare-commit-msg hook content
2575 prepareCommitMsgHook := `#!/bin/bash
2576# Add Co-Authored-By and Change-ID trailers to commit messages
2577# Check if these trailers already exist before adding them
2578
2579commit_file="$1"
2580COMMIT_SOURCE="$2"
2581
2582# Skip for merges, squashes, or when using a commit template
2583if [ "$COMMIT_SOURCE" = "template" ] || [ "$COMMIT_SOURCE" = "merge" ] || \
2584 [ "$COMMIT_SOURCE" = "squash" ]; then
2585 exit 0
2586fi
2587
2588commit_msg=$(cat "$commit_file")
2589
2590needs_co_author=true
2591needs_change_id=true
2592
2593# Check if commit message already has Co-Authored-By trailer
2594if grep -q "Co-Authored-By: sketch <hello@sketch.dev>" "$commit_file"; then
2595 needs_co_author=false
2596fi
2597
2598# Check if commit message already has Change-ID trailer
2599if grep -q "Change-ID: s[a-f0-9]\+k" "$commit_file"; then
2600 needs_change_id=false
2601fi
2602
2603# Only modify if at least one trailer needs to be added
2604if [ "$needs_co_author" = true ] || [ "$needs_change_id" = true ]; then
Josh Bleecher Snyderb509a5d2025-05-23 15:49:42 +00002605 # Ensure there's a proper blank line before trailers
2606 if [ -s "$commit_file" ]; then
2607 # Check if file ends with newline by reading last character
2608 last_char=$(tail -c 1 "$commit_file")
2609
2610 if [ "$last_char" != "" ]; then
2611 # File doesn't end with newline - add two newlines (complete line + blank line)
2612 echo "" >> "$commit_file"
2613 echo "" >> "$commit_file"
2614 else
2615 # File ends with newline - check if we already have a blank line
2616 last_line=$(tail -1 "$commit_file")
2617 if [ -n "$last_line" ]; then
2618 # Last line has content - add one newline for blank line
2619 echo "" >> "$commit_file"
2620 fi
2621 # If last line is empty, we already have a blank line - don't add anything
2622 fi
Josh Bleecher Snyder039fc342025-05-14 21:24:12 +00002623 fi
2624
2625 # Add trailers if needed
2626 if [ "$needs_co_author" = true ]; then
2627 echo "Co-Authored-By: sketch <hello@sketch.dev>" >> "$commit_file"
2628 fi
2629
2630 if [ "$needs_change_id" = true ]; then
2631 change_id=$(openssl rand -hex 8)
2632 echo "Change-ID: s${change_id}k" >> "$commit_file"
2633 fi
2634fi
2635`
2636
2637 // Update or create the post-commit hook
2638 err = updateOrCreateHook(filepath.Join(hooksDir, "post-commit"), postCommitHook, "<last_commit_message>")
2639 if err != nil {
2640 return fmt.Errorf("failed to set up post-commit hook: %w", err)
2641 }
2642
2643 // Update or create the prepare-commit-msg hook
2644 err = updateOrCreateHook(filepath.Join(hooksDir, "prepare-commit-msg"), prepareCommitMsgHook, "Add Co-Authored-By and Change-ID trailers")
2645 if err != nil {
2646 return fmt.Errorf("failed to set up prepare-commit-msg hook: %w", err)
2647 }
2648
2649 return nil
2650}
2651
2652// updateOrCreateHook creates a new hook file or updates an existing one
2653// by appending the new content if it doesn't already contain it.
2654func updateOrCreateHook(hookPath, content, distinctiveLine string) error {
2655 // Check if the hook already exists
2656 buf, err := os.ReadFile(hookPath)
2657 if os.IsNotExist(err) {
2658 // Hook doesn't exist, create it
2659 err = os.WriteFile(hookPath, []byte(content), 0o755)
2660 if err != nil {
2661 return fmt.Errorf("failed to create hook: %w", err)
2662 }
2663 return nil
2664 }
2665 if err != nil {
2666 return fmt.Errorf("error reading existing hook: %w", err)
2667 }
2668
2669 // Hook exists, check if our content is already in it by looking for a distinctive line
2670 code := string(buf)
2671 if strings.Contains(code, distinctiveLine) {
2672 // Already contains our content, nothing to do
2673 return nil
2674 }
2675
2676 // Append our content to the existing hook
2677 f, err := os.OpenFile(hookPath, os.O_APPEND|os.O_WRONLY, 0o755)
2678 if err != nil {
2679 return fmt.Errorf("failed to open hook for appending: %w", err)
2680 }
2681 defer f.Close()
2682
2683 // Ensure there's a newline at the end of the existing content if needed
2684 if len(code) > 0 && !strings.HasSuffix(code, "\n") {
2685 _, err = f.WriteString("\n")
2686 if err != nil {
2687 return fmt.Errorf("failed to add newline to hook: %w", err)
2688 }
2689 }
2690
2691 // Add a separator before our content
2692 _, err = f.WriteString("\n# === Added by Sketch ===\n" + content)
2693 if err != nil {
2694 return fmt.Errorf("failed to append to hook: %w", err)
2695 }
2696
2697 return nil
2698}
Sean McCullough138ec242025-06-02 22:42:06 +00002699
Philip Zeyliger254c49f2025-07-17 17:26:24 -07002700// configurePassthroughUpstream configures git remotes
2701// Adds an upstream remote pointing to the same as origin
2702// Sets the refspec for upstream and fetch such that both
2703// fetch the upstream's things into refs/remotes/upstream/foo
2704// The typical scenario is:
2705//
2706// github - laptop - sketch container
2707// "upstream" "origin"
2708func (a *Agent) configurePassthroughUpstream(ctx context.Context) error {
2709 // Get the origin remote URL
2710 cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
2711 cmd.Dir = a.workingDir
2712 originURLBytes, err := cmd.CombinedOutput()
2713 if err != nil {
2714 return fmt.Errorf("failed to get origin URL: %s: %w", originURLBytes, err)
2715 }
2716 originURL := strings.TrimSpace(string(originURLBytes))
2717
2718 // Check if upstream remote already exists
2719 cmd = exec.CommandContext(ctx, "git", "remote", "get-url", "upstream")
2720 cmd.Dir = a.workingDir
2721 if _, err := cmd.CombinedOutput(); err != nil {
2722 // upstream remote doesn't exist, create it
2723 cmd = exec.CommandContext(ctx, "git", "remote", "add", "upstream", originURL)
2724 cmd.Dir = a.workingDir
2725 if out, err := cmd.CombinedOutput(); err != nil {
2726 return fmt.Errorf("failed to add upstream remote: %s: %w", out, err)
2727 }
2728 slog.InfoContext(ctx, "added upstream remote", "url", originURL)
2729 } else {
2730 // upstream remote exists, update its URL
2731 cmd = exec.CommandContext(ctx, "git", "remote", "set-url", "upstream", originURL)
2732 cmd.Dir = a.workingDir
2733 if out, err := cmd.CombinedOutput(); err != nil {
2734 return fmt.Errorf("failed to set upstream remote URL: %s: %w", out, err)
2735 }
2736 slog.InfoContext(ctx, "updated upstream remote URL", "url", originURL)
2737 }
2738
2739 // Add the upstream refspec to the upstream remote
2740 cmd = exec.CommandContext(ctx, "git", "config", "remote.upstream.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2741 cmd.Dir = a.workingDir
2742 if out, err := cmd.CombinedOutput(); err != nil {
2743 return fmt.Errorf("failed to set upstream fetch refspec: %s: %w", out, err)
2744 }
2745
2746 // Add the same refspec to the origin remote
2747 cmd = exec.CommandContext(ctx, "git", "config", "--add", "remote.origin.fetch", "+refs/remotes/origin/*:refs/remotes/upstream/*")
2748 cmd.Dir = a.workingDir
2749 if out, err := cmd.CombinedOutput(); err != nil {
2750 return fmt.Errorf("failed to add upstream refspec to origin: %s: %w", out, err)
2751 }
2752
2753 slog.InfoContext(ctx, "configured passthrough upstream", "origin_url", originURL)
2754 return nil
2755}
2756
Philip Zeyliger0113be52025-06-07 23:53:41 +00002757// SkabandAddr returns the skaband address if configured
2758func (a *Agent) SkabandAddr() string {
2759 if a.config.SkabandClient != nil {
2760 return a.config.SkabandClient.Addr()
2761 }
2762 return ""
2763}
bankseanbdc68892025-07-28 17:28:13 -07002764
2765// ExternalMsg represents a message from a source external to the agent/user conversation,
2766// such as the outcome of a github workflow run.
2767type ExternalMessage struct {
2768 MessageType string `json:"message_type"`
2769 Body any `json:"body"`
2770 TextContent string `json:"text_content"`
Giorgi Lekveishvili6a4ca202025-07-05 20:06:27 +04002771
2772// SendInitialMessage sends an LLM-generated initial message
2773func (a *Agent) SendInitialMessage(ctx context.Context) {
2774 introPrompt := `Based on your role as a Sketch coding assistant and the codebase information provided, write a brief, professional introduction to the user:
27751. Greet the user and tell them your name.
27762. Retrieve and analyze current project's dodo environment.
27773. Give dodo environment summary to the user.
27784. Ask what they'd like to work on. Be concise and helpful.`
2779
2780 // The LLM response will automatically be pushed to outbox via OnResponse()
2781 _, err := a.convo.SendUserTextMessage(introPrompt)
2782 if err != nil {
2783 a.pushToOutbox(ctx, AgentMessage{
2784 Type: AgentMessageType,
2785 Content: "Hello! I'm your Sketch coding assistant. What would you like to work on today?",
2786 EndOfTurn: true,
2787 })
2788 return
2789 }
bankseanbdc68892025-07-28 17:28:13 -07002790}